From d52977b63ea2445c37c644fd2d6cd1c793b357f7 Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Sun, 27 Sep 2026 12:18:00 +0200 Subject: [PATCH 01/30] runtime-async outlining fallback --- .../.FSharp.Compiler.Service/11.0.200.md | 1 + docs/runtime-async.md | 55 +-- src/Compiler/Optimize/Optimizer.fs | 44 ++- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 315 +++++++++++------- .../Optimize/RuntimeAsyncAnalysis.fsi | 12 +- .../Language/RuntimeAsyncTests.fs | 191 ++++++++++- 6 files changed, 468 insertions(+), 150 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 6212188b65f..31b94e164a7 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,6 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) +* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending callbacks once, rather than duplicating their construction at each invocation. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) ### Changed diff --git a/docs/runtime-async.md b/docs/runtime-async.md index aa9f5118663..d0ed8ad6d2e 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -144,25 +144,39 @@ Inline values whose bodies contain a return marker or an `AsyncHelpers` suspension are recursively specialized at their call sites, including when optimization is disabled. The analysis follows inline and local values with a cycle guard, and `InlineIfLambda` arguments are forced through when the caller -is already in a runtime-async context. The optimizer follows nested inline -calls and does not create a generated helper method for the specialized -suspension fragment, keeping every suspension in the eventual runtime-async -method. - -After specialization, lambda arguments are substituted and their applications -are beta-reduced before and after runtime-async reoptimization. This includes -debug-point-wrapped lambdas, compiler-generated `let` wrappers, curried -applications, and multi-argument lambdas. -That step is required for computation-expression shapes where `Bind` returns a -closure containing `Await`, and later `Combine`/`Delay` calls apply that closure. +is already in a runtime-async context. Ordinary inlining is tried first; +applications of returned lambdas are reduced when their remaining arguments +can safely move across the returned closure's construction. This handles +computation-expression shapes where `Bind` returns a closure containing +`Await`, and later `Combine`/`Delay` calls apply it. + +If a compiler-owned `InlineIfLambda` callback still contains a suspension +after inlining, a fully rewritable, single-argument callback can be outlined +instead. Each branch of its construction produces a callback returning +`ValueTask<'T>`; the generated callback body contains +`__runtimeAsyncReturnValueTask`, and its invocations are replaced by +`AsyncHelpers.Await` inside the enclosing runtime-async method. The callback +is constructed once, so conditional construction effects and state captured +across repeated invocations are preserved. Only the specialized copy changes +calling convention, not the exported inline definition or source signature. +This applies within an existing runtime-async body or an inline expansion +that produces one. Opaque consumers, escaping callbacks, unsupported callback +shapes, and unsafe byref or pinned captures are not outlined; suspensions +remaining in an ordinary method are still diagnosed. + +Outlining retains the callback closure and generates an async `Invoke` method +for each specialized callback shape. Calls now return a `ValueTask<'T>` for +the enclosing method to await, trading method code size and possible +allocations for preservation of callback state. It does not distribute a +continuation across conditional branches. The outlined body receives the +same byref-use checks and exception handler rewrite as a directly marked +runtime-async method. When runtime-async specialization is forced in a debug build, the builder -combinator is copied with its definition-site debug ranges remarked before -arguments are substituted. User continuation arguments keep their own ranges, -so `let!`, `do!`, `yield`, and other -computation-expression statements remain associated with the source that -authored them without exposing the implementation ranges of `Run`, `Bind`, -`Combine`, or `Yield`. +combinator is copied with its definition-site debug ranges remarked at the +call site. User continuation arguments keep their own ranges, and the marked +method retains a call-site sequence point even when forced inlining reduces +its intermediate closures. Dead branches eliminated by optimization do not reach code generation and do not produce a suspension-outside-runtime-async diagnostic. @@ -235,10 +249,9 @@ body (`DecideExpr`), promoting its free mutable locals to reference cells so the synthesized closure and the enclosing scope share them. `InvokeFast` is not a separate runtime-async path. It is the closure-erasure -shape for an indirect call with multiple arguments. Fragment substitution and -beta reduction happen before closure erasure; if a suspending fragment survives -until an indirect `InvokeFast` call, it is still outside a runtime-async method -and is rejected by code generation. +shape for an indirect call with multiple arguments. Forced inlining and +eligible callback outlining happen before closure erasure; a suspension left +behind an opaque indirect invocation is rejected by code generation. ## Runtime capability check diff --git a/src/Compiler/Optimize/Optimizer.fs b/src/Compiler/Optimize/Optimizer.fs index 7deff4a8341..1fc32b256e3 100644 --- a/src/Compiler/Optimize/Optimizer.fs +++ b/src/Compiler/Optimize/Optimizer.fs @@ -2728,6 +2728,13 @@ let EtaExpandUnderAppliedValBinding g expr = | EtaFloatableValLet g (bind, body, m, etaExpanded) -> floatEtaCaptures bind body m etaExpanded | _ -> expr +let PrepareRuntimeAsyncBody g (env: IncrementalOptimizationEnv) body = + for v in GetRuntimeAsyncNonPreservableUses g body do + if env.runtimeAsyncReportedRanges.Add v.Range then + errorR(Error(FSComp.SR.ilRuntimeAsyncLocalUsedAfterSuspension(RichText.mkText v.DisplayName), v.Range)) + + RewriteRuntimeAsyncExceptionHandlers g body + /// Optimize/analyze an expression let rec OptimizeExpr cenv (env: IncrementalOptimizationEnv) expr = cenv.stackGuard.Guard(fun () -> @@ -2806,11 +2813,13 @@ let rec OptimizeExpr cenv (env: IncrementalOptimizationEnv) expr = | Expr.App (f, fty, tyargs, _, m) when runtimeAsyncReturn.IsSome -> let info = runtimeAsyncReturn.Value let bodyR, bodyInfo = OptimizeExpr cenv { env with runtimeAsyncContext = true } info.Body - for v in GetRuntimeAsyncNonPreservableUses g bodyR do - if env.runtimeAsyncReportedRanges.Add v.Range then - errorR(Error(FSComp.SR.ilRuntimeAsyncLocalUsedAfterSuspension(RichText.mkText v.DisplayName), v.Range)) + let bodyR = + if g.langVersion.SupportsFeature LanguageFeature.RuntimeAsync then + OutlineRuntimeAsyncCallback g (RuntimeAsyncAnalyzer g) true (PrepareRuntimeAsyncBody g env) bodyR + else + bodyR - let bodyR = RewriteRuntimeAsyncExceptionHandlers g bodyR + let bodyR = PrepareRuntimeAsyncBody g env bodyR Expr.App(f, fty, tyargs, [ bodyR ], m), { bodyInfo with HasEffect = true @@ -3934,15 +3943,24 @@ and TryInlineApplication cenv env finfo (valExpr: Expr) (tyargs: TType list, arg | None -> false let reoptimizeRuntimeAsync reduced = - let reduced = InlineRuntimeAsyncLambdaArgument g containsRuntimeAsyncFragment reduced - - let reduced = - if containsRuntimeAsyncFragment reduced then - fst (OptimizeExpr cenv { env with runtimeAsyncContext = true } reduced) - else - reduced - - InlineRuntimeAsyncLambdaArgument g containsRuntimeAsyncFragment reduced + if containsRuntimeAsyncFragment reduced then + let preserveCallSite = not cenv.settings.LocalOptimizationsEnabled + let cenv = + { cenv with + settings = + { cenv.settings with + alwaysInline = true + localOptUser = Some true } } + let result = fst (OptimizeExpr cenv { env with runtimeAsyncContext = true } reduced) + match runtimeAsyncAnalyzer with + | Some analyzer -> + let result = ReduceRuntimeAsyncReturnedClosureApplications g analyzer result + let result = + OutlineRuntimeAsyncCallback g analyzer env.runtimeAsyncContext (PrepareRuntimeAsyncBody g env) result + if preserveCallSite then PreserveRuntimeAsyncCallSiteDebugPoint g m result else result + | None -> result + else + reduced let mustInlineRuntimeAsync = match runtimeAsyncAnalyzer, stripExpr valExpr with diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index e76aa5caeed..d9443bc0cf0 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -9,6 +9,7 @@ open Internal.Utilities.Library.Extras open System.Collections.Generic open FSharp.Compiler +open FSharp.Compiler.AbstractIL.IL open FSharp.Compiler.DiagnosticsLogger open FSharp.Compiler.TcGlobals open FSharp.Compiler.Text @@ -138,137 +139,223 @@ let ShouldForceRuntimeAsyncApplication (analyzer: RuntimeAsyncAnalyzer) runtimeA | _ -> false) args) -let rec private IsRuntimeAsyncEffectFree expr = - match stripExpr expr with - | Expr.Const _ - | Expr.Lambda _ - | Expr.TyLambda _ -> true - | Expr.Val(vref, _, _) -> not vref.IsMutable && not vref.IsTypeFunction - | Expr.App(funcExpr, _, _, [], _) -> IsRuntimeAsyncEffectFree funcExpr - | Expr.Op(TOp.Tuple _, _, args, _) - | Expr.Op(TOp.AnonRecd _, _, args, _) -> List.forall IsRuntimeAsyncEffectFree args - | _ -> false - -let InlineRuntimeAsyncLambdaArgument (g: TcGlobals) (isRuntimeAsyncFragment: Expr -> bool) expr = - let rec isLambdaExpression expr = +let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) expr = + let rec effectFree expr = match stripExpr expr with - | Expr.DebugPoint(_, innerExpr) -> isLambdaExpression innerExpr - | Expr.Let(TBind(_, rhs, _), innerExpr, _, _) -> IsRuntimeAsyncEffectFree rhs && isLambdaExpression innerExpr + | Expr.Const _ | Expr.Lambda _ | Expr.TyLambda _ -> true + | Expr.Val(vref, _, _) -> not vref.IsMutable && not vref.IsTypeFunction + | Expr.App(funcExpr, _, _, [], _) -> effectFree funcExpr | _ -> false - let rec stripLambdaDebugPoints expr = - match expr with - | Expr.DebugPoint(_, innerExpr) -> - match stripDebugPoints innerExpr with - | Expr.Lambda _ - | Expr.TyLambda _ -> stripLambdaDebugPoints innerExpr - | _ -> expr - | Expr.Lambda(unique, ctorThisValOpt, baseValOpt, valParams, bodyExpr, m, overallType) -> - match bodyExpr with - | Expr.DebugPoint(_, innerExpr) -> - match stripDebugPoints innerExpr with - | Expr.Lambda _ - | Expr.TyLambda _ -> - Expr.Lambda(unique, ctorThisValOpt, baseValOpt, valParams, stripLambdaDebugPoints bodyExpr, m, overallType) - | _ -> expr - | _ -> expr - | Expr.TyLambda(unique, typeParams, bodyExpr, m, overallType) -> - match bodyExpr with - | Expr.DebugPoint(_, innerExpr) -> - match stripDebugPoints innerExpr with - | Expr.Lambda _ - | Expr.TyLambda _ -> Expr.TyLambda(unique, typeParams, stripLambdaDebugPoints bodyExpr, m, overallType) - | _ -> expr - | _ -> expr - | _ -> expr - - let rec betaReduceLambdaApplication expr = - let rec apply f fty tyargs args m = - match args with - | [] -> None - | firstArg :: rest -> - let f = stripLambdaDebugPoints f - - match f with - | Expr.Let(bind, body, mLet, _) -> apply body (tyOfExpr g body) tyargs args m |> Option.map (mkLetBind mLet bind) - | Expr.Lambda(_, _, _, valParams, body, _, _) when - valParams.Length = 1 - && not rest.IsEmpty - && (IsRuntimeAsyncEffectFree body || List.forall IsRuntimeAsyncEffectFree rest) - -> - let reduced = MakeApplicationAndBetaReduce g (f, fty, [ tyargs ], [ firstArg ], m) - - match reduced with - | Expr.Let(bind, body, mLet, _) -> - match apply body (tyOfExpr g body) [] rest m with - | Some bodyR -> Some(mkLetBind mLet bind bodyR) - | None -> Some(mkAppsAux g reduced (tyOfExpr g reduced) [] rest m) - | _ -> Some reduced - | Expr.Lambda _ - | Expr.TyLambda _ -> Some(MakeApplicationAndBetaReduce g (f, fty, [ tyargs ], args, m)) - | _ -> None - - match stripDebugPoints expr with - | Expr.App(f, fty, tyargs, args, m) -> apply f fty tyargs args m + let rec apply f fty tyargs args m = + match f, args with + | Expr.DebugPoint(_, inner), _ when + match stripDebugPoints inner with + | Expr.Lambda _ -> true + | _ -> false + -> + apply inner fty tyargs args m + | Expr.Let(binding, body, mLet, _), _ -> + apply body (tyOfExpr g body) tyargs args m + |> Option.map (mkLetBind mLet binding) + | Expr.Sequential(first, rest, kind, mSeq), _ when List.forall effectFree args -> + apply rest (tyOfExpr g rest) tyargs args m + |> Option.map (fun rest -> Expr.Sequential(first, rest, kind, mSeq)) + | Expr.Match(point, matchRange, tree, targets, mMatch, _), _ when List.forall effectFree args -> + let targets = + targets + |> Array.map (fun (TTarget(vals, body, flags)) -> + apply body (tyOfExpr g body) tyargs args m + |> Option.map (fun body -> TTarget(vals, body, flags))) + + if targets.Length > 0 && Array.forall Option.isSome targets then + let targets = targets |> Array.map Option.get + Some(Expr.Match(point, matchRange, tree, targets, mMatch, tyOfExpr g targets[0].TargetExpression)) + else + None + | Expr.Lambda(_, _, _, [ _ ], _, _, _), first :: (_ :: _ as rest) when List.forall effectFree rest -> + let reduced = MakeApplicationAndBetaReduce g (f, fty, [ tyargs ], [ first ], m) + + let rec applyRest expr = + match expr with + | Expr.Let(binding, body, mLet, _) -> applyRest body |> Option.map (mkLetBind mLet binding) + | _ -> apply expr (tyOfExpr g expr) [] rest m + + applyRest reduced + | Expr.Lambda _, _ :: _ -> Some(MakeApplicationAndBetaReduce g (f, fty, [ tyargs ], args, m)) + | _ when not (analyzer.ContainsSuspension f) && List.forall effectFree args -> Some(mkAppsAux g f fty [ tyargs ] args m) | _ -> None - let mkRwenv (preIntercept: (Expr -> Expr) -> Expr -> Expr option) : ExprRewritingEnv = + let rwenv = { - PreIntercept = Some preIntercept + PreIntercept = None PreInterceptBinding = None - PostTransform = betaReduceLambdaApplication + PostTransform = + (fun expression -> + match expression with + | Expr.App((Expr.Lambda _ | Expr.Let _ | Expr.Match _ | Expr.DebugPoint _) as f, fty, tyargs, args, m) when + not args.IsEmpty && analyzer.ContainsSuspension expression + -> + apply f fty tyargs args m + | _ -> None) RewriteQuotations = false - StackGuard = StackGuard("InlineRuntimeAsyncLambdaArgument") + StackGuard = StackGuard("ReduceRuntimeAsyncReturnedClosureApplications") } - let rec inlineBinding (boundVal: Val) boundExpr body = - match boundExpr with - | Expr.DebugPoint(point, inner) -> Expr.DebugPoint(point, inlineBinding boundVal inner body) - | Expr.Let(binding, rest, m, _) -> mkLetBind m binding (inlineBinding boundVal rest body) - | Expr.Sequential(first, rest, NormalSeq, m) -> Expr.Sequential(first, inlineBinding boundVal rest body, NormalSeq, m) - | _ -> - let rwenv = - mkRwenv (fun _ expr -> - match betaReduceLambdaApplication expr with - | Some reduced -> Some reduced - | None -> - match stripExpr expr with - | Expr.App(f, _, tyargs, args, m) -> - match stripDebugPoints f with - | Expr.Val(vref, _, _) when valEq boundVal vref.Deref -> - Some( - MakeApplicationAndBetaReduce - g - (copyExpr g CloneAll boundExpr, tyOfExpr g boundExpr, [ tyargs ], args, m) - ) - | _ -> None - | Expr.Val(vref, _, _) when valEq boundVal vref.Deref -> Some(copyExpr g CloneAll boundExpr) - | _ -> None) - - RewriteExpr rwenv body + RewriteExpr rwenv expr +let PreserveRuntimeAsyncCallSiteDebugPoint (g: TcGlobals) m expr = let rwenv = - mkRwenv (fun cont expr -> - match stripExpr expr with - | Expr.Let(TBind(boundVal, boundExpr, _), body, _, _) when - (boundVal.InlineIfLambda - && (isLambdaExpression boundExpr - || isRuntimeAsyncFragment boundExpr - || match stripExpr boundExpr with - | Expr.App(_, _, _, args, _) -> List.isEmpty args - | _ -> true)) - || (isLambdaExpression boundExpr && isRuntimeAsyncFragment boundExpr) - -> - if not boundVal.InlineIfLambda then - boundVal.SetInlineIfLambda() - - Some(cont (inlineBinding boundVal boundExpr body)) - | _ -> None) + { + PreIntercept = None + PreInterceptBinding = None + PostTransform = + (fun expression -> + match expression, TryGetRuntimeAsyncReturn g expression with + | Expr.App(f, fty, tyargs, [ body ], range), Some _ -> + match body with + | Expr.DebugPoint _ -> None + | _ -> Some(Expr.App(f, fty, tyargs, [ mkDebugPoint m body ], range)) + | _ -> None) + RewriteQuotations = false + StackGuard = StackGuard("PreserveRuntimeAsyncCallSiteDebugPoint") + } RewriteExpr rwenv expr +let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext prepareBody expr = + let rec outlineBranches resultTy expr = + match expr with + | Expr.Lambda(_, None, None, [ parameter ], body, m, _) -> + let marker = g.cgh__runtimeAsyncReturnValueTask_vref + let body = prepareBody body + + let markedBody = + primMkApp (exprForValRef m marker, marker.Type) [ resultTy ] [ body ] m + + Some(mkLambda m parameter (markedBody, tyOfExpr g markedBody)) + | Expr.DebugPoint(point, body) -> + outlineBranches resultTy body + |> Option.map (fun body -> Expr.DebugPoint(point, body)) + | Expr.Sequential(first, rest, kind, m) -> + outlineBranches resultTy rest + |> Option.map (fun rest -> Expr.Sequential(first, rest, kind, m)) + | Expr.Let(binding, rest, m, _) -> outlineBranches resultTy rest |> Option.map (mkLetBind m binding) + | Expr.Match(point, matchRange, tree, targets, m, _) -> + let targets = + targets + |> Array.map (fun (TTarget(vals, body, flags)) -> + outlineBranches resultTy body + |> Option.map (fun body -> TTarget(vals, body, flags))) + + if targets.Length > 0 && Array.forall Option.isSome targets then + let targets = targets |> Array.map Option.get + Some(Expr.Match(point, matchRange, tree, targets, m, tyOfExpr g targets[0].TargetExpression)) + else + None + | _ -> None + + let rec outline expr = + match expr with + | Expr.Let(TBind(callback, construction, point), continuation, m, _) when + callback.InlineIfLambda && analyzer.ContainsSuspension construction + -> + match tryDestFunTy g callback.Type with + | ValueSome(argTy, resultTy) when + (runtimeAsyncContext || (TryGetRuntimeAsyncReturn g continuation).IsSome) + && not (isByrefLikeTy g m argTy || isByrefLikeTy g m resultTy) + -> + let freeVals = (freeInExpr CollectLocals construction).FreeLocals + + let canCapture = + freeVals + |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) + + match canCapture, outlineBranches resultTy construction with + | true, Some construction -> + let callbackTy = tyOfExpr g construction + let outlined, outlinedExpr = mkCompGenLocal m "runtimeAsyncCallback" callbackTy + let mutable invalidUse = false + + let rwenv = + { + PreIntercept = + Some(fun rewrite expression -> + match expression with + | Expr.App(Expr.Val(vref, _, _), _, [], [ arg ], callRange) when valEq callback vref.Deref -> + let invocation = + mkApps g ((outlinedExpr, callbackTy), [], [ rewrite arg ], callRange) + + let resultTy = tyOfExpr g expression + let valueTaskRef = g.FindSysILTypeRef "System.Threading.Tasks.ValueTask`1" + + let awaitRef = + mkILMethRef ( + g.FindSysILTypeRef "System.Runtime.CompilerServices.AsyncHelpers", + ILCallingConv.Static, + "Await", + 1, + [ ILType.Value(mkILTySpec (valueTaskRef, [ mkILTyvarTy 0us ])) ], + mkILTyvarTy 0us + ) + + Some( + Expr.Op( + TOp.ILCall( + false, + false, + false, + false, + ValUseFlag.NormalValUse, + false, + false, + awaitRef, + [], + [ resultTy ], + [ resultTy ] + ), + [], + [ invocation ], + callRange + ) + ) + | Expr.Val(vref, _, _) when valEq callback vref.Deref -> + invalidUse <- true + Some expression + | _ -> None) + PreInterceptBinding = None + PostTransform = (fun _ -> None) + RewriteQuotations = false + StackGuard = StackGuard("OutlineRuntimeAsyncCallback") + } + + let continuation = RewriteExpr rwenv continuation + + let quotedUse = + ExistsExpr + (function + | Expr.Quote(body, _, _, _, _) -> + ExistsExpr + (function + | Expr.Val(vref, _, _) -> valEq callback vref.Deref + | _ -> false) + body + | _ -> false) + continuation + + if invalidUse || quotedUse then + expr + else + mkLet point m outlined construction continuation + | _ -> expr + | _ -> expr + | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (outline continuation) + | _ -> expr + + outline expr + type private RuntimeAsyncFlowSummary = { MaySuspend: bool diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi index 54518fc605d..c1778225158 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi @@ -24,7 +24,17 @@ val ShouldForceRuntimeAsyncApplication: args: Expr list -> bool -val InlineRuntimeAsyncLambdaArgument: g: TcGlobals -> isRuntimeAsyncFragment: (Expr -> bool) -> expr: Expr -> Expr +val OutlineRuntimeAsyncCallback: + g: TcGlobals -> + analyzer: RuntimeAsyncAnalyzer -> + runtimeAsyncContext: bool -> + prepareBody: (Expr -> Expr) -> + expr: Expr -> + Expr + +val ReduceRuntimeAsyncReturnedClosureApplications: g: TcGlobals -> analyzer: RuntimeAsyncAnalyzer -> expr: Expr -> Expr + +val PreserveRuntimeAsyncCallSiteDebugPoint: g: TcGlobals -> m: FSharp.Compiler.Text.range -> expr: Expr -> Expr val GetRuntimeAsyncNonPreservableUses: g: TcGlobals -> expr: Expr -> Val list diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 0531b24d541..9a839306a58 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1175,7 +1175,7 @@ let main _ = [] [] -[] +[] let ``runtime async evaluates conditional callback construction once`` (optimize: bool) = FSharp """ module RuntimeAsyncConditionalCallbackConstructionTest @@ -1220,6 +1220,195 @@ let main _ = |> compileExeAndRun |> shouldSucceed +[] +[] +[] +[] +[] +let ``Issue 20577 preserves stateful conditional fold callbacks`` (optimize: bool, useList: bool) = + let source = $""" +module RuntimeAsyncConditionalFoldTest + +open System.Collections.Generic +open System.Threading.Tasks +open RuntimeTaskBuilder.RuntimeTask + +let run deduplicate (ready: Task) = + runtimeTask {{ + let! initial = ready + let folder = + if deduplicate then + let seen = HashSet() + fun total item -> if seen.Add item then total + item else total + else + fun total item -> total + item + + return + {if useList then "List.fold folder initial [ 1; 1; 2 ]" else "Array.fold folder initial [| 1; 1; 2 |]"} + }} + +[] +let main _ = + for deduplicate in [ true; false ] do + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let work = run deduplicate gate.Task + if work.IsCompleted then failwith "Expected suspension" + gate.SetResult 0 + let expected = if deduplicate then 3 else 4 + if work.GetAwaiter().GetResult() <> expected then failwith "Callback state was lost" + 0 +""" + + FsFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTaskBuilder.fs")) + |> withAdditionalSourceFile (FsSourceWithFileName "RuntimeAsyncConditionalFoldTest.fs" source) + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + +[] +[] +[] +let ``runtime async outlines a stateful conditional callback from an imported inline`` (optimize: bool) = + let library = + FSharp """ +module RuntimeAsyncCallbackLibrary + +let inline invokeTwice ([] callback: unit -> int) = + callback() + callback() +""" + |> withName "RuntimeAsyncCallbackLibrary" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + + FSharp """ +module RuntimeAsyncImportedCallbackTest + +open System.Collections.Generic +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices +open RuntimeAsyncCallbackLibrary + +let mutable constructions = 0 +let mutable invocations = 0 + +let run (gate: Task) deduplicate = + StateMachineHelpers.__runtimeAsyncReturn ( + invokeTwice ( + if deduplicate then + constructions <- constructions + 1 + let seen = HashSet() + fun () -> + invocations <- invocations + 1 + let first = seen.Add 1 + AsyncHelpers.Await gate + if first then 1 else 0 + else + fun () -> 2)) + +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let work = run gate.Task true + if work.IsCompleted || constructions <> 1 || invocations <> 1 then + failwith "Callback construction or first invocation did not run before suspension" + gate.SetResult(()) + if work.GetAwaiter().GetResult() <> 1 || constructions <> 1 || invocations <> 2 then + failwith "Callback state was not shared" + if (run gate.Task false).GetAwaiter().GetResult() <> 4 || constructions <> 1 || invocations <> 2 then + failwith "The other branch changed" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> withReferences [ library ] + |> compileExeAndRun + |> shouldSucceed + +[] +[] +[] +let ``runtime async outlined callbacks preserve cleanup after suspension`` (optimize: bool) = + FSharp """ +module RuntimeAsyncOutlinedCleanupTest + +open System.Collections.Generic +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let mutable cleaned = 0 + +let inline invokeTwice ([] callback: unit -> int) = + StateMachineHelpers.__runtimeAsyncReturn (callback() + callback()) + +let run (gate: Task) (cleanupGate: Task) (cleanupStarted: TaskCompletionSource) = + invokeTwice ( + if true then + let seen = HashSet() + fun () -> + try + let first = seen.Add 1 + AsyncHelpers.Await gate + if first then 1 else 0 + finally + cleanupStarted.TrySetResult(()) |> ignore + AsyncHelpers.Await cleanupGate + cleaned <- cleaned + 1 + else + fun () -> 0) + +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let cleanupGate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let cleanupStarted = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let work = run gate.Task cleanupGate.Task cleanupStarted + if work.IsCompleted || cleaned <> 0 then failwith "Cleanup ran before suspension" + gate.SetResult(()) + if not (cleanupStarted.Task.Wait(5000)) || work.IsCompleted then + failwith "Cleanup did not suspend" + cleanupGate.SetResult(()) + if work.GetAwaiter().GetResult() <> 1 || cleaned <> 2 then + failwith "Cleanup or callback state was lost" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + +[] +[] +[] +let ``runtime async does not outline opaque callback consumers`` (optimize: bool) = + FSharp """ +module RuntimeAsyncOpaqueCallbackTest + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +[] +let consume (callback: unit -> int) = callback() + +let inline invoke ([] callback: unit -> int) = + StateMachineHelpers.__runtimeAsyncReturn (consume callback) + +let run (gate: Task) = + invoke (if true then fun () -> AsyncHelpers.Await gate else fun () -> 0) +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compile + |> shouldFail + |> withErrorCode 3918 + [] [] [] From a4824bfd28a5846ef510b01bb9930e1bf7a647f3 Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:24:46 +0200 Subject: [PATCH 02/30] hande delegates --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- docs/runtime-async.md | 9 + src/Compiler/CodeGen/IlxGen.fs | 11 +- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 86 ++++++++- .../Language/RuntimeAsyncTests.fs | 175 ++++++++++++++++++ 5 files changed, 279 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 31b94e164a7..2d6e3bebfaa 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending callbacks once, rather than duplicating their construction at each invocation. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577)) +* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending callbacks once, rather than duplicating their construction at each invocation. Inline eligible delegate callbacks used by async-sequence consumers; reject any suspension left in an unmarked delegate method at compile time. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) ### Changed diff --git a/docs/runtime-async.md b/docs/runtime-async.md index d0ed8ad6d2e..d403cf2953e 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -164,6 +164,15 @@ that produces one. Opaque consumers, escaping callbacks, unsupported callback shapes, and unsafe byref or pinned captures are not outlined; suspensions remaining in an ordinary method are still diagnosed. +For a compiler-owned `InlineIfLambda` delegate invoked exactly once, the +optimizer can instead inline its `Invoke` body into the marked method. +Precomputations that capture the delegate's inputs stay at their construction +site, so a pending operation is created once before the invocation. A delegate +that escapes or is consumed opaquely cannot be converted this way. IlxGen +checks each generated delegate `Invoke` as its own method: an `Await` left +there without a return marker produces FS3918, even when the enclosing method +is runtime-async. Exported inline definitions remain unchanged. + Outlining retains the callback closure and generates an async `Invoke` method for each specialized callback shape. Calls now return a `ValueTask<'T>` for the enclosing method to await, trading method code size and possible diff --git a/src/Compiler/CodeGen/IlxGen.fs b/src/Compiler/CodeGen/IlxGen.fs index ecaa2503a8e..eaf7e83e83d 100644 --- a/src/Compiler/CodeGen/IlxGen.fs +++ b/src/Compiler/CodeGen/IlxGen.fs @@ -8049,7 +8049,8 @@ and GenDelegateExpr cenv cgbuf eenvouter expr (TObjExprMethod(slotsig, _attribs, methInst, leadingArgs) -> if - ilMethodDirectlyBindable + not (IsRuntimeAsyncSuspensionMethod g ilMethRef) + && ilMethodDirectlyBindable (Optimizer.ExprHasEffect Optimizer.EffectContext.Emit) g tmvs @@ -8138,8 +8139,14 @@ and GenDelegateExpr cenv cgbuf eenvouter expr (TObjExprMethod(slotsig, _attribs, let ilDelegeeParams, ilDelegeeRet = GenActualSlotsig m cenv envForDelegeeUnderTypars slotsig methTyparsOfOverridingMethod tmvs + // The enclosing method's runtime-async marker does not apply to the delegate's Invoke. let envForDelegeeMeth = - AddStorageForLocalVals g (List.mapi (fun i v -> (v, Arg(i + numthis))) tmvs) envForDelegeeUnderTypars + let env = + AddStorageForLocalVals g (List.mapi (fun i v -> (v, Arg(i + numthis))) tmvs) envForDelegeeUnderTypars + + { env with + inRuntimeAsyncMethod = false + } let ilMethodBody = CodeGenMethodForExpr diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index d9443bc0cf0..63bf7aa5b56 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -225,7 +225,7 @@ let PreserveRuntimeAsyncCallSiteDebugPoint (g: TcGlobals) m expr = RewriteExpr rwenv expr -let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext prepareBody expr = +let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext prepareBody (expr: Expr) = let rec outlineBranches resultTy expr = match expr with | Expr.Lambda(_, None, None, [ parameter ], body, m, _) -> @@ -257,8 +257,92 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) None | _ -> None + let rec peelDelegateConstruction expr = + match expr with + | Expr.DebugPoint(point, rest) -> + peelDelegateConstruction rest + |> Option.map (fun (delegateExpr, wrap) -> delegateExpr, (fun body -> Expr.DebugPoint(point, wrap body))) + | Expr.Sequential(first, rest, kind, m) -> + peelDelegateConstruction rest + |> Option.map (fun (delegateExpr, wrap) -> delegateExpr, (fun body -> Expr.Sequential(first, wrap body, kind, m))) + | Expr.Let(binding, rest, m, _) -> + peelDelegateConstruction rest + |> Option.map (fun (delegateExpr, wrap) -> delegateExpr, (fun body -> mkLetBind m binding (wrap body))) + | NewDelegateExpr g (_, [ parameter ], body, _, _) when + not ( + isByrefLikeTy g expr.Range parameter.Type + || isByrefLikeTy g expr.Range (tyOfExpr g body) + ) + -> + Some(expr, id) + | _ -> None + let rec outline expr = match expr with + | Expr.Let(TBind(callback, construction, _), continuation, _, _) when + callback.InlineIfLambda + && (runtimeAsyncContext || (TryGetRuntimeAsyncReturn g continuation).IsSome) + && isFSharpDelegateTy g callback.Type + && analyzer.ContainsSuspension construction + -> + match peelDelegateConstruction construction with + | None -> expr + | Some(delegateExpr, wrap) -> + let freeVals = (freeInExpr CollectLocals construction).FreeLocals + + let canCapture = + freeVals + |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g expr.Range v.Type)) + + if not canCapture then + expr + else + let mutable invocations = 0 + let mutable invalidUse = false + + let rwenv = + { + PreIntercept = + Some(fun rewrite expression -> + match expression with + | DelegateInvokeExpr g (invokeRef, invokeTy, tyargs, Expr.Val(vref, _, _), arg, callRange) when + valEq callback vref.Deref + -> + invocations <- invocations + 1 + + Some( + MakeFSharpDelegateInvokeAndTryBetaReduce + g + (invokeRef, delegateExpr, invokeTy, tyargs, rewrite arg, callRange) + ) + | Expr.Val(vref, _, _) when valEq callback vref.Deref -> + invalidUse <- true + Some expression + | _ -> None) + PreInterceptBinding = None + PostTransform = (fun _ -> None) + RewriteQuotations = false + StackGuard = StackGuard("InlineRuntimeAsyncDelegateInvocation") + } + + let continuation = RewriteExpr rwenv continuation + + let quotedUse = + ExistsExpr + (function + | Expr.Quote(body, _, _, _, _) -> + ExistsExpr + (function + | Expr.Val(vref, _, _) -> valEq callback vref.Deref + | _ -> false) + body + | _ -> false) + continuation + + if invocations = 1 && not invalidUse && not quotedUse then + outline (wrap continuation) + else + expr | Expr.Let(TBind(callback, construction, point), continuation, m, _) when callback.InlineIfLambda && analyzer.ContainsSuspension construction -> diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 9a839306a58..29182cc42a4 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -993,6 +993,181 @@ let ``runtime async enumerable builder fixture executes`` (optimize: bool) = |> compileExeAndRun |> shouldSucceed +[] +[] +[] +[] +[] +let ``runtime async delegate await does not repeat a pending sequence move`` (optimize: bool, recipe: bool) = + FSharp $""" +module RuntimeAsyncDelegateAwait + +open System +open System.Collections.Generic +open System.Threading +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +type Started<'T> = delegate of unit -> 'T +let mutable constructions = 0 + +type Builder() = + member inline _.Delay([] code: unit -> 'T) = code + member inline _.Run([] code: unit -> 'T) : Task<'T> = + StateMachineHelpers.__runtimeAsyncReturn(code()) + member inline _.Source(work: ValueTask<'T>) = + constructions <- constructions + 1 + Started(fun () -> AsyncHelpers.Await work) + member inline _.Bind([] work: Started<'T>, [] next: 'T -> 'U) = + next (work.Invoke()) + member inline _.While([] guard: unit -> bool, [] body: unit -> unit) = + while guard() do body() + member inline _.Zero() = () + member inline _.Combine(_: unit, [] next: unit -> 'T) = next() + member inline _.Return(value) = value + +let builder = Builder() + +type GuardedEnumerator(gate: Task) = + let mutable active = 0 + let mutable count = 0 + interface IAsyncEnumerator with + member _.Current = count + member _.MoveNextAsync() = + if Interlocked.Exchange(&active, 1) = 1 then + invalidOp "MoveNextAsync cannot be called concurrently." + StateMachineHelpers.__runtimeAsyncReturnValueTask( + try + AsyncHelpers.Await gate + count <- count + 1 + count = 1 + finally + Interlocked.Exchange(&active, 0) |> ignore) + member _.DisposeAsync() = ValueTask() + +let collect (e: IAsyncEnumerator) = + builder {{ + let values = ResizeArray() + while! e.MoveNextAsync() do + values.Add(e.Current) + return values.ToArray() + }} + +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let enumerator = + if {if recipe then "true" else "false"} then + let source = StateMachineHelpers.__runtimeAsyncSequence(fun () -> seq {{ + do AsyncHelpers.Await gate.Task + yield 1 + }}) + source.GetAsyncEnumerator() + else + GuardedEnumerator(gate.Task) :> IAsyncEnumerator + let pending = collect enumerator + if pending.IsCompleted then + pending.GetAwaiter().GetResult() |> ignore + failwith "Expected a pending first move" + if constructions <> 1 then failwith "Expected one construction before suspension" + gate.SetResult(()) + if pending.GetAwaiter().GetResult() <> [| 1 |] || constructions <> 2 then + failwith "Unexpected sequence values or delegate constructions" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + +[] +[] +[] +let ``runtime async inlines imported InlineIfLambda delegate invocations`` (optimize: bool) = + let library = + FSharp """ +module ImportedDelegateAwait +open Microsoft.FSharp.Core.CompilerServices + +type Started<'T> = delegate of unit -> 'T + +let inline run ([] callback: Started) = + StateMachineHelpers.__runtimeAsyncReturn(callback.Invoke()) +""" + |> withName "ImportedDelegateAwait" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + + FSharp """ +module RuntimeAsyncImportedDelegate +open System.Threading.Tasks +open System.Runtime.CompilerServices +open ImportedDelegateAwait + +let run (gate: Task) (cleanup: Task) (entered: TaskCompletionSource) = + ImportedDelegateAwait.run (Started(fun () -> + try + AsyncHelpers.Await gate + finally + entered.SetResult(()) + AsyncHelpers.Await cleanup)) + +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let cleanup = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let entered = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let pending = run gate.Task cleanup.Task entered + if pending.IsCompleted then failwith "Expected suspension" + gate.SetResult 42 + if not (entered.Task.Wait(5000)) || pending.IsCompleted then + failwith "Cleanup must suspend before completing the delegate" + cleanup.SetResult(()) + if pending.GetAwaiter().GetResult() <> 42 then failwith "Lost delegate await" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> withReferences [ library ] + |> compileExeAndRun + |> shouldSucceed + +[] +[] +[] +[] +[] +let ``runtime async rejects an unmarked delegate method`` (optimize: bool, marked: bool) = + let invocation = "consume (Started(fun () -> AsyncHelpers.Await gate))" + let body = + if marked then + $"StateMachineHelpers.__runtimeAsyncReturn ({invocation})" + else + invocation + + FSharp $""" +module RuntimeAsyncUnmarkedDelegate +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +type Started<'T> = delegate of unit -> 'T + +[] +let consume (callback: Started) = callback.Invoke() + +let run (gate: Task) = {body} +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compile + |> shouldFail + |> withErrorCode 3918 + [] let ``runtime async enumerable CE debug points stay at call sites`` () = let source = From 169d50448fdbd73b0e856b68224732fd7be6e0d5 Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:51:24 +0200 Subject: [PATCH 03/30] Outline residual runtime-async delegate invokes --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- docs/runtime-async.md | 23 +- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 337 ++++++++++-------- .../Language/RuntimeAsyncTests.fs | 92 +++++ 4 files changed, 294 insertions(+), 160 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 2d6e3bebfaa..0bbd27a158e 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending callbacks once, rather than duplicating their construction at each invocation. Inline eligible delegate callbacks used by async-sequence consumers; reject any suspension left in an unmarked delegate method at compile time. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577)) +* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending functions and delegates once, rather than duplicating their construction at each invocation. Inline single-use delegates used by async-sequence consumers; outline rewritable repeated delegate invokes and reject suspensions left in unmarked delegate methods. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) ### Changed diff --git a/docs/runtime-async.md b/docs/runtime-async.md index d403cf2953e..8bef30ed631 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -150,10 +150,10 @@ can safely move across the returned closure's construction. This handles computation-expression shapes where `Bind` returns a closure containing `Await`, and later `Combine`/`Delay` calls apply it. -If a compiler-owned `InlineIfLambda` callback still contains a suspension -after inlining, a fully rewritable, single-argument callback can be outlined -instead. Each branch of its construction produces a callback returning -`ValueTask<'T>`; the generated callback body contains +If a compiler-owned `InlineIfLambda` function or delegate still contains a +suspension after inlining, a fully rewritable, single-argument callback can +be outlined instead. Each branch of its construction produces an F# callback +returning `ValueTask<'T>`; the generated callback body contains `__runtimeAsyncReturnValueTask`, and its invocations are replaced by `AsyncHelpers.Await` inside the enclosing runtime-async method. The callback is constructed once, so conditional construction effects and state captured @@ -165,13 +165,16 @@ shapes, and unsafe byref or pinned captures are not outlined; suspensions remaining in an ordinary method are still diagnosed. For a compiler-owned `InlineIfLambda` delegate invoked exactly once, the -optimizer can instead inline its `Invoke` body into the marked method. +optimizer first tries to inline its `Invoke` body into the marked method. Precomputations that capture the delegate's inputs stay at their construction -site, so a pending operation is created once before the invocation. A delegate -that escapes or is consumed opaquely cannot be converted this way. IlxGen -checks each generated delegate `Invoke` as its own method: an `Await` left -there without a return marker produces FS3918, even when the enclosing method -is runtime-async. Exported inline definitions remain unchanged. +site, so a pending operation is created once before the invocation. If it +cannot be reduced and all invokes are directly rewritable, the delegate is +outlined as above; conditional construction and repeated invokes preserve +the same captured state. A delegate that escapes or is consumed opaquely +cannot be converted. IlxGen checks each generated delegate `Invoke` as its +own method: an `Await` left there without a return marker produces FS3918, +even when the enclosing method is runtime-async. Exported inline definitions +remain unchanged. Outlining retains the callback closure and generates an async `Invoke` method for each specialized callback shape. Calls now return a `ValueTask<'T>` for diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 63bf7aa5b56..ac3ec545f09 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -228,7 +228,8 @@ let PreserveRuntimeAsyncCallSiteDebugPoint (g: TcGlobals) m expr = let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext prepareBody (expr: Expr) = let rec outlineBranches resultTy expr = match expr with - | Expr.Lambda(_, None, None, [ parameter ], body, m, _) -> + | Expr.Lambda(_, None, None, [ parameter ], body, m, _) + | NewDelegateExpr g (_, [ parameter ], body, m, _) -> let marker = g.cgh__runtimeAsyncReturnValueTask_vref let body = prepareBody body @@ -257,6 +258,17 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) None | _ -> None + let rec tryDelegateSignature expr = + match expr with + | NewDelegateExpr g (_, [ parameter ], body, _, _) -> Some(parameter.Type, tyOfExpr g body) + | Expr.DebugPoint(_, rest) + | Expr.Sequential(_, rest, _, _) + | Expr.Let(_, rest, _, _) -> tryDelegateSignature rest + | Expr.Match(_, _, _, targets, _, _) when targets.Length > 0 -> + let (TTarget(_, body, _)) = targets[0] + tryDelegateSignature body + | _ -> None + let rec peelDelegateConstruction expr = match expr with | Expr.DebugPoint(point, rest) -> @@ -277,164 +289,191 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) Some(expr, id) | _ -> None - let rec outline expr = - match expr with - | Expr.Let(TBind(callback, construction, _), continuation, _, _) when - callback.InlineIfLambda - && (runtimeAsyncContext || (TryGetRuntimeAsyncReturn g continuation).IsSome) - && isFSharpDelegateTy g callback.Type - && analyzer.ContainsSuspension construction - -> - match peelDelegateConstruction construction with - | None -> expr - | Some(delegateExpr, wrap) -> - let freeVals = (freeInExpr CollectLocals construction).FreeLocals + let tryInlineDelegate callback construction continuation m = + match peelDelegateConstruction construction with + | None -> None + | Some(delegateExpr, wrap) -> + let freeVals = (freeInExpr CollectLocals construction).FreeLocals - let canCapture = - freeVals - |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g expr.Range v.Type)) + let canCapture = + freeVals + |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) - if not canCapture then - expr + if not canCapture then + None + else + let mutable invocations = 0 + let mutable invalidUse = false + + let rwenv = + { + PreIntercept = + Some(fun rewrite expression -> + match expression with + | DelegateInvokeExpr g (invokeRef, invokeTy, tyargs, Expr.Val(vref, _, _), arg, callRange) when + valEq callback vref.Deref + -> + invocations <- invocations + 1 + + Some( + MakeFSharpDelegateInvokeAndTryBetaReduce + g + (invokeRef, delegateExpr, invokeTy, tyargs, rewrite arg, callRange) + ) + | Expr.Val(vref, _, _) when valEq callback vref.Deref -> + invalidUse <- true + Some expression + | _ -> None) + PreInterceptBinding = None + PostTransform = (fun _ -> None) + RewriteQuotations = false + StackGuard = StackGuard("InlineRuntimeAsyncDelegateInvocation") + } + + let continuation = RewriteExpr rwenv continuation + + let quotedUse = + ExistsExpr + (function + | Expr.Quote(body, _, _, _, _) -> + ExistsExpr + (function + | Expr.Val(vref, _, _) -> valEq callback vref.Deref + | _ -> false) + body + | _ -> false) + continuation + + if invocations = 1 && not invalidUse && not quotedUse then + Some(wrap continuation) else - let mutable invocations = 0 - let mutable invalidUse = false - - let rwenv = - { - PreIntercept = - Some(fun rewrite expression -> - match expression with - | DelegateInvokeExpr g (invokeRef, invokeTy, tyargs, Expr.Val(vref, _, _), arg, callRange) when - valEq callback vref.Deref - -> - invocations <- invocations + 1 - - Some( - MakeFSharpDelegateInvokeAndTryBetaReduce - g - (invokeRef, delegateExpr, invokeTy, tyargs, rewrite arg, callRange) - ) - | Expr.Val(vref, _, _) when valEq callback vref.Deref -> - invalidUse <- true - Some expression - | _ -> None) - PreInterceptBinding = None - PostTransform = (fun _ -> None) - RewriteQuotations = false - StackGuard = StackGuard("InlineRuntimeAsyncDelegateInvocation") - } - - let continuation = RewriteExpr rwenv continuation - - let quotedUse = - ExistsExpr - (function - | Expr.Quote(body, _, _, _, _) -> - ExistsExpr - (function - | Expr.Val(vref, _, _) -> valEq callback vref.Deref - | _ -> false) - body - | _ -> false) - continuation - - if invocations = 1 && not invalidUse && not quotedUse then - outline (wrap continuation) - else - expr + None + + let rec outline expr = + match expr with | Expr.Let(TBind(callback, construction, point), continuation, m, _) when callback.InlineIfLambda && analyzer.ContainsSuspension construction -> - match tryDestFunTy g callback.Type with - | ValueSome(argTy, resultTy) when - (runtimeAsyncContext || (TryGetRuntimeAsyncReturn g continuation).IsSome) - && not (isByrefLikeTy g m argTy || isByrefLikeTy g m resultTy) - -> - let freeVals = (freeInExpr CollectLocals construction).FreeLocals - - let canCapture = - freeVals - |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) - - match canCapture, outlineBranches resultTy construction with - | true, Some construction -> - let callbackTy = tyOfExpr g construction - let outlined, outlinedExpr = mkCompGenLocal m "runtimeAsyncCallback" callbackTy - let mutable invalidUse = false - - let rwenv = - { - PreIntercept = - Some(fun rewrite expression -> - match expression with - | Expr.App(Expr.Val(vref, _, _), _, [], [ arg ], callRange) when valEq callback vref.Deref -> - let invocation = - mkApps g ((outlinedExpr, callbackTy), [], [ rewrite arg ], callRange) - - let resultTy = tyOfExpr g expression - let valueTaskRef = g.FindSysILTypeRef "System.Threading.Tasks.ValueTask`1" - - let awaitRef = - mkILMethRef ( - g.FindSysILTypeRef "System.Runtime.CompilerServices.AsyncHelpers", - ILCallingConv.Static, - "Await", - 1, - [ ILType.Value(mkILTySpec (valueTaskRef, [ mkILTyvarTy 0us ])) ], - mkILTyvarTy 0us - ) + let canOutline = + runtimeAsyncContext || (TryGetRuntimeAsyncReturn g continuation).IsSome - Some( - Expr.Op( - TOp.ILCall( - false, - false, - false, - false, - ValUseFlag.NormalValUse, - false, - false, - awaitRef, + let inlined = + if canOutline && isFSharpDelegateTy g callback.Type then + tryInlineDelegate callback construction continuation m + else + None + + match inlined with + | Some reduced -> outline reduced + | None -> + let shape = + match tryDestFunTy g callback.Type with + | ValueSome(argTy, resultTy) -> Some(argTy, resultTy, false) + | ValueNone when isFSharpDelegateTy g callback.Type -> + tryDelegateSignature construction + |> Option.map (fun (argTy, resultTy) -> argTy, resultTy, true) + | _ -> None + + match shape with + | Some(argTy, resultTy, isDelegate) when canOutline && not (isByrefLikeTy g m argTy || isByrefLikeTy g m resultTy) -> + let freeVals = (freeInExpr CollectLocals construction).FreeLocals + + let canCapture = + freeVals + |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) + + match canCapture, outlineBranches resultTy construction with + | true, Some construction -> + let callbackTy = tyOfExpr g construction + let outlined, outlinedExpr = mkCompGenLocal m "runtimeAsyncCallback" callbackTy + let mutable invalidUse = false + + let rwenv = + { + PreIntercept = + Some(fun rewrite expression -> + let call = + match expression with + | Expr.App(Expr.Val(vref, _, _), _, [], [ arg ], callRange) when + not isDelegate && valEq callback vref.Deref + -> + Some(arg, callRange) + | DelegateInvokeExpr g (_, _, _, Expr.Val(vref, _, _), arg, callRange) when + isDelegate && valEq callback vref.Deref + -> + Some(arg, callRange) + | _ -> None + + match call with + | Some(arg, callRange) -> + let invocation = + mkApps g ((outlinedExpr, callbackTy), [], [ rewrite arg ], callRange) + + let resultTy = tyOfExpr g expression + let valueTaskRef = g.FindSysILTypeRef "System.Threading.Tasks.ValueTask`1" + + let awaitRef = + mkILMethRef ( + g.FindSysILTypeRef "System.Runtime.CompilerServices.AsyncHelpers", + ILCallingConv.Static, + "Await", + 1, + [ ILType.Value(mkILTySpec (valueTaskRef, [ mkILTyvarTy 0us ])) ], + mkILTyvarTy 0us + ) + + Some( + Expr.Op( + TOp.ILCall( + false, + false, + false, + false, + ValUseFlag.NormalValUse, + false, + false, + awaitRef, + [], + [ resultTy ], + [ resultTy ] + ), [], - [ resultTy ], - [ resultTy ] - ), - [], - [ invocation ], - callRange + [ invocation ], + callRange + ) ) - ) - | Expr.Val(vref, _, _) when valEq callback vref.Deref -> - invalidUse <- true - Some expression - | _ -> None) - PreInterceptBinding = None - PostTransform = (fun _ -> None) - RewriteQuotations = false - StackGuard = StackGuard("OutlineRuntimeAsyncCallback") - } - - let continuation = RewriteExpr rwenv continuation - - let quotedUse = - ExistsExpr - (function - | Expr.Quote(body, _, _, _, _) -> - ExistsExpr - (function - | Expr.Val(vref, _, _) -> valEq callback vref.Deref - | _ -> false) - body - | _ -> false) - continuation - - if invalidUse || quotedUse then - expr - else - mkLet point m outlined construction continuation + | None -> + match expression with + | Expr.Val(vref, _, _) when valEq callback vref.Deref -> + invalidUse <- true + Some expression + | _ -> None) + PreInterceptBinding = None + PostTransform = (fun _ -> None) + RewriteQuotations = false + StackGuard = StackGuard("OutlineRuntimeAsyncCallback") + } + + let continuation = RewriteExpr rwenv continuation + + let quotedUse = + ExistsExpr + (function + | Expr.Quote(body, _, _, _, _) -> + ExistsExpr + (function + | Expr.Val(vref, _, _) -> valEq callback vref.Deref + | _ -> false) + body + | _ -> false) + continuation + + if invalidUse || quotedUse then + expr + else + mkLet point m outlined construction continuation + | _ -> expr | _ -> expr - | _ -> expr | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (outline continuation) | _ -> expr diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 29182cc42a4..5dbd3a6659f 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1135,6 +1135,98 @@ let main _ = |> compileExeAndRun |> shouldSucceed +[] +[] +[] +let ``runtime async outlines repeated conditional delegate invocations`` (optimize: bool) = + let library = + FSharp """ +module ImportedDelegateCallbacks +open Microsoft.FSharp.Core.CompilerServices + +type Started<'T> = delegate of unit -> 'T + +let inline invokeTwice ([] callback: Started) = + callback.Invoke() + callback.Invoke() +""" + |> withName "ImportedDelegateCallbacks" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + + FSharp """ +module RuntimeAsyncRepeatedDelegate +open System.Collections.Generic +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices +open ImportedDelegateCallbacks + +let mutable constructions = 0 +let mutable invocations = 0 + +let run (gate: Task) deduplicate = + StateMachineHelpers.__runtimeAsyncReturn ( + invokeTwice ( + if deduplicate then + constructions <- constructions + 1 + let seen = HashSet() + Started(fun () -> + invocations <- invocations + 1 + let first = seen.Add 1 + AsyncHelpers.Await gate + if first then 1 else 0) + else + Started(fun () -> 2))) + +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let pending = run gate.Task true + if pending.IsCompleted || constructions <> 1 || invocations <> 1 then + failwith "Callback construction or first invocation changed" + gate.SetResult(()) + if pending.GetAwaiter().GetResult() <> 1 || constructions <> 1 || invocations <> 2 then + failwith "Repeated calls did not share callback state" + if (run gate.Task false).GetAwaiter().GetResult() <> 4 || constructions <> 1 then + failwith "Other branch changed" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> withReferences [ library ] + |> compileExeAndRun + |> shouldSucceed + +[] +[] +[] +let ``runtime async does not outline delegates passed to opaque consumers`` (optimize: bool) = + FSharp """ +module RuntimeAsyncOpaqueDelegate + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +type Started<'T> = delegate of unit -> 'T + +[] +let consume (callback: Started) = callback.Invoke() + +let inline invoke ([] callback: Started) = + StateMachineHelpers.__runtimeAsyncReturn(callback.Invoke() + consume callback) + +let run (gate: Task) = + invoke (if true then Started(fun () -> AsyncHelpers.Await gate) else Started(fun () -> 0)) +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compile + |> shouldFail + |> withErrorCode 3918 + [] [] [] From f1581fe5180df63bda9180c4ecf592c14e1c889a Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:05:33 +0200 Subject: [PATCH 04/30] release notes --- docs/release-notes/.FSharp.Compiler.Service/11.0.200.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 0bbd27a158e..6ea60f3df12 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending functions and delegates once, rather than duplicating their construction at each invocation. Inline single-use delegates used by async-sequence consumers; outline rewritable repeated delegate invokes and reject suspensions left in unmarked delegate methods. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577)) +* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending functions and delegates once, rather than duplicating their construction at each invocation. Inline single-use delegates used by async-sequence consumers; outline rewritable repeated delegate invokes and reject suspensions left in unmarked delegate methods. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) ### Changed From b7803141d9e174a4e07b57701a697a2c6c9a643f Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Sun, 27 Sep 2026 15:08:21 +0200 Subject: [PATCH 05/30] Outline nested runtime-async delegate sources --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- docs/runtime-async.md | 24 +- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 307 +++++++----------- .../Language/RuntimeAsyncTests.fs | 65 +++- 4 files changed, 190 insertions(+), 208 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 6ea60f3df12..a7d8a820e31 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending functions and delegates once, rather than duplicating their construction at each invocation. Inline single-use delegates used by async-sequence consumers; outline rewritable repeated delegate invokes and reject suspensions left in unmarked delegate methods. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending functions and delegates once, rather than duplicating their construction at each invocation. Outline residual direct delegate invokes, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) ### Changed diff --git a/docs/runtime-async.md b/docs/runtime-async.md index 8bef30ed631..12367bfc513 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -164,17 +164,19 @@ that produces one. Opaque consumers, escaping callbacks, unsupported callback shapes, and unsafe byref or pinned captures are not outlined; suspensions remaining in an ordinary method are still diagnosed. -For a compiler-owned `InlineIfLambda` delegate invoked exactly once, the -optimizer first tries to inline its `Invoke` body into the marked method. -Precomputations that capture the delegate's inputs stay at their construction -site, so a pending operation is created once before the invocation. If it -cannot be reduced and all invokes are directly rewritable, the delegate is -outlined as above; conditional construction and repeated invokes preserve -the same captured state. A delegate that escapes or is consumed opaquely -cannot be converted. IlxGen checks each generated delegate `Invoke` as its -own method: an `Await` left there without a return marker produces FS3918, -even when the enclosing method is runtime-async. Exported inline definitions -remain unchanged. +The ordinary optimizer can inline an `Invoke` on a constructed delegate. +If inlining leaves an `InlineIfLambda` delegate bound to a local, directly +rewritable invocations use the outlining fallback instead. When the +construction itself contains inner callback bindings, eligible inner +callbacks are outlined before the outer delegate is marked, so each +`Await` stays inside a runtime-async method. Precomputations that capture +the delegate's inputs remain at its construction site, so a pending operation +is created once before invocation; conditional construction and repeated +invokes preserve the same captured state. A delegate that escapes or is +consumed opaquely cannot be converted. IlxGen checks each generated delegate +`Invoke` as its own method: an `Await` left there without a return marker +produces FS3918, even when the enclosing method is runtime-async. Exported +inline definitions remain unchanged. Outlining retains the callback closure and generates an async `Invoke` method for each specialized callback shape. Calls now return a `ValueTask<'T>` for diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index ac3ec545f09..791963eca02 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -269,86 +269,6 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) tryDelegateSignature body | _ -> None - let rec peelDelegateConstruction expr = - match expr with - | Expr.DebugPoint(point, rest) -> - peelDelegateConstruction rest - |> Option.map (fun (delegateExpr, wrap) -> delegateExpr, (fun body -> Expr.DebugPoint(point, wrap body))) - | Expr.Sequential(first, rest, kind, m) -> - peelDelegateConstruction rest - |> Option.map (fun (delegateExpr, wrap) -> delegateExpr, (fun body -> Expr.Sequential(first, wrap body, kind, m))) - | Expr.Let(binding, rest, m, _) -> - peelDelegateConstruction rest - |> Option.map (fun (delegateExpr, wrap) -> delegateExpr, (fun body -> mkLetBind m binding (wrap body))) - | NewDelegateExpr g (_, [ parameter ], body, _, _) when - not ( - isByrefLikeTy g expr.Range parameter.Type - || isByrefLikeTy g expr.Range (tyOfExpr g body) - ) - -> - Some(expr, id) - | _ -> None - - let tryInlineDelegate callback construction continuation m = - match peelDelegateConstruction construction with - | None -> None - | Some(delegateExpr, wrap) -> - let freeVals = (freeInExpr CollectLocals construction).FreeLocals - - let canCapture = - freeVals - |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) - - if not canCapture then - None - else - let mutable invocations = 0 - let mutable invalidUse = false - - let rwenv = - { - PreIntercept = - Some(fun rewrite expression -> - match expression with - | DelegateInvokeExpr g (invokeRef, invokeTy, tyargs, Expr.Val(vref, _, _), arg, callRange) when - valEq callback vref.Deref - -> - invocations <- invocations + 1 - - Some( - MakeFSharpDelegateInvokeAndTryBetaReduce - g - (invokeRef, delegateExpr, invokeTy, tyargs, rewrite arg, callRange) - ) - | Expr.Val(vref, _, _) when valEq callback vref.Deref -> - invalidUse <- true - Some expression - | _ -> None) - PreInterceptBinding = None - PostTransform = (fun _ -> None) - RewriteQuotations = false - StackGuard = StackGuard("InlineRuntimeAsyncDelegateInvocation") - } - - let continuation = RewriteExpr rwenv continuation - - let quotedUse = - ExistsExpr - (function - | Expr.Quote(body, _, _, _, _) -> - ExistsExpr - (function - | Expr.Val(vref, _, _) -> valEq callback vref.Deref - | _ -> false) - body - | _ -> false) - continuation - - if invocations = 1 && not invalidUse && not quotedUse then - Some(wrap continuation) - else - None - let rec outline expr = match expr with | Expr.Let(TBind(callback, construction, point), continuation, m, _) when @@ -357,123 +277,120 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) let canOutline = runtimeAsyncContext || (TryGetRuntimeAsyncReturn g continuation).IsSome - let inlined = - if canOutline && isFSharpDelegateTy g callback.Type then - tryInlineDelegate callback construction continuation m - else - None - - match inlined with - | Some reduced -> outline reduced - | None -> - let shape = - match tryDestFunTy g callback.Type with - | ValueSome(argTy, resultTy) -> Some(argTy, resultTy, false) - | ValueNone when isFSharpDelegateTy g callback.Type -> - tryDelegateSignature construction - |> Option.map (fun (argTy, resultTy) -> argTy, resultTy, true) - | _ -> None - - match shape with - | Some(argTy, resultTy, isDelegate) when canOutline && not (isByrefLikeTy g m argTy || isByrefLikeTy g m resultTy) -> - let freeVals = (freeInExpr CollectLocals construction).FreeLocals - - let canCapture = - freeVals - |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) - - match canCapture, outlineBranches resultTy construction with - | true, Some construction -> - let callbackTy = tyOfExpr g construction - let outlined, outlinedExpr = mkCompGenLocal m "runtimeAsyncCallback" callbackTy - let mutable invalidUse = false - - let rwenv = - { - PreIntercept = - Some(fun rewrite expression -> - let call = - match expression with - | Expr.App(Expr.Val(vref, _, _), _, [], [ arg ], callRange) when - not isDelegate && valEq callback vref.Deref - -> - Some(arg, callRange) - | DelegateInvokeExpr g (_, _, _, Expr.Val(vref, _, _), arg, callRange) when - isDelegate && valEq callback vref.Deref - -> - Some(arg, callRange) - | _ -> None - - match call with - | Some(arg, callRange) -> - let invocation = - mkApps g ((outlinedExpr, callbackTy), [], [ rewrite arg ], callRange) - - let resultTy = tyOfExpr g expression - let valueTaskRef = g.FindSysILTypeRef "System.Threading.Tasks.ValueTask`1" - - let awaitRef = - mkILMethRef ( - g.FindSysILTypeRef "System.Runtime.CompilerServices.AsyncHelpers", - ILCallingConv.Static, - "Await", - 1, - [ ILType.Value(mkILTySpec (valueTaskRef, [ mkILTyvarTy 0us ])) ], - mkILTyvarTy 0us - ) - - Some( - Expr.Op( - TOp.ILCall( - false, - false, - false, - false, - ValUseFlag.NormalValUse, - false, - false, - awaitRef, - [], - [ resultTy ], - [ resultTy ] - ), + let shape = + match tryDestFunTy g callback.Type with + | ValueSome(argTy, resultTy) -> Some(argTy, resultTy, false) + | ValueNone when isFSharpDelegateTy g callback.Type -> + tryDelegateSignature construction + |> Option.map (fun (argTy, resultTy) -> argTy, resultTy, true) + | _ -> None + + match shape with + | Some(argTy, resultTy, isDelegate) when canOutline && not (isByrefLikeTy g m argTy || isByrefLikeTy g m resultTy) -> + let freeVals = (freeInExpr CollectLocals construction).FreeLocals + + let canCapture = + freeVals + |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) + + let outlinedConstruction = + if canCapture then + outlineBranches resultTy (outline construction) + else + None + + match outlinedConstruction with + | Some construction -> + let callbackTy = tyOfExpr g construction + let outlined, outlinedExpr = mkCompGenLocal m "runtimeAsyncCallback" callbackTy + let mutable invalidUse = false + + let rwenv = + { + PreIntercept = + Some(fun rewrite expression -> + let call = + match expression with + | Expr.App(Expr.Val(vref, _, _), _, [], [ arg ], callRange) when + not isDelegate && valEq callback vref.Deref + -> + Some(arg, callRange) + | DelegateInvokeExpr g (_, _, _, Expr.Val(vref, _, _), arg, callRange) when + isDelegate && valEq callback vref.Deref + -> + Some(arg, callRange) + | _ -> None + + match call with + | Some(arg, callRange) -> + let invocation = + mkApps g ((outlinedExpr, callbackTy), [], [ rewrite arg ], callRange) + + let resultTy = tyOfExpr g expression + let valueTaskRef = g.FindSysILTypeRef "System.Threading.Tasks.ValueTask`1" + + let awaitRef = + mkILMethRef ( + g.FindSysILTypeRef "System.Runtime.CompilerServices.AsyncHelpers", + ILCallingConv.Static, + "Await", + 1, + [ ILType.Value(mkILTySpec (valueTaskRef, [ mkILTyvarTy 0us ])) ], + mkILTyvarTy 0us + ) + + Some( + Expr.Op( + TOp.ILCall( + false, + false, + false, + false, + ValUseFlag.NormalValUse, + false, + false, + awaitRef, [], - [ invocation ], - callRange - ) + [ resultTy ], + [ resultTy ] + ), + [], + [ invocation ], + callRange ) - | None -> - match expression with - | Expr.Val(vref, _, _) when valEq callback vref.Deref -> - invalidUse <- true - Some expression - | _ -> None) - PreInterceptBinding = None - PostTransform = (fun _ -> None) - RewriteQuotations = false - StackGuard = StackGuard("OutlineRuntimeAsyncCallback") - } - - let continuation = RewriteExpr rwenv continuation - - let quotedUse = - ExistsExpr - (function - | Expr.Quote(body, _, _, _, _) -> - ExistsExpr - (function - | Expr.Val(vref, _, _) -> valEq callback vref.Deref - | _ -> false) - body - | _ -> false) - continuation - - if invalidUse || quotedUse then - expr - else - mkLet point m outlined construction continuation - | _ -> expr - | _ -> expr + ) + | None -> + match expression with + | Expr.Val(vref, _, _) when valEq callback vref.Deref -> + invalidUse <- true + Some expression + | _ -> None) + PreInterceptBinding = None + PostTransform = (fun _ -> None) + RewriteQuotations = false + StackGuard = StackGuard("OutlineRuntimeAsyncCallback") + } + + let continuation = RewriteExpr rwenv continuation + + let quotedUse = + ExistsExpr + (function + | Expr.Quote(body, _, _, _, _) -> + ExistsExpr + (function + | Expr.Val(vref, _, _) -> valEq callback vref.Deref + | _ -> false) + body + | _ -> false) + continuation + + if invalidUse || quotedUse then + expr + else + mkLet point m outlined construction continuation + | None -> expr + | _ -> expr | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (outline continuation) | _ -> expr diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 5dbd3a6659f..1da329f7f18 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1085,7 +1085,7 @@ let main _ = [] [] [] -let ``runtime async inlines imported InlineIfLambda delegate invocations`` (optimize: bool) = +let ``runtime async handles imported InlineIfLambda delegate invocations`` (optimize: bool) = let library = FSharp """ module ImportedDelegateAwait @@ -1135,6 +1135,69 @@ let main _ = |> compileExeAndRun |> shouldSucceed +[] +[] +[] +let ``runtime async merges delegate sources`` (optimize: bool) = + FSharp """ +module RuntimeAsyncMergedDelegates +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +type Started<'T> = delegate of unit -> 'T + +type Builder() = + member inline _.Delay([] code: unit -> 'T) = code + member inline _.Source(task: Task<'T>) = Started(fun () -> AsyncHelpers.Await task) + member inline _.MergeSources([] left: Started<'A>, [] right: Started<'B>) = + Started(fun () -> struct (left.Invoke(), right.Invoke())) + member inline _.Bind([] source: Started<'T>, [] next: 'T -> 'U) = + next (source.Invoke()) + member inline _.Return(value: 'T) = value + member inline _.Run([] code: unit -> 'T) : Task<'T> = + StateMachineHelpers.__runtimeAsyncReturn (code()) + +let builder = Builder() + +let run (left: Task) (right: Task) = + builder { + let! x = left + and! y = right + return x + y + } + +let runThree (first: Task) (second: Task) (third: Task) = + builder { + let! x = first + and! y = second + and! z = third + return x + y + z + } + +[] +let main _ = + let left = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let right = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let pending = run left.Task right.Task + if pending.IsCompleted then failwith "Expected a pending result" + left.SetResult 1 + if pending.IsCompleted then failwith "Right source was not awaited" + right.SetResult 2 + if pending.GetAwaiter().GetResult() <> 3 then failwith "Wrong result" + let third = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let nested = runThree left.Task right.Task third.Task + if nested.IsCompleted then failwith "Nested merge completed before the third source" + third.SetResult 3 + if nested.GetAwaiter().GetResult() <> 6 then failwith "Wrong nested result" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + [] [] [] From 0eb10c3adfd2de1a1ed74add8e5001075dc715a4 Mon Sep 17 00:00:00 2001 From: Jakub Majocha <1760221+majocha@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:00:58 +0200 Subject: [PATCH 06/30] Inline single-use runtime-async delegate sources before outlining Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- docs/runtime-async.md | 25 +++-- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 106 +++++++++++++++++- .../Language/RuntimeAsyncTests.fs | 70 ++++++++++++ 4 files changed, 185 insertions(+), 18 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index a7d8a820e31..e6141489048 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending functions and delegates once, rather than duplicating their construction at each invocation. Outline residual direct delegate invokes, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending functions and delegates once, rather than duplicating their construction at each invocation. Inline single-use delegates through effect-free conditional construction before outlining residual invokes, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) ### Changed diff --git a/docs/runtime-async.md b/docs/runtime-async.md index 12367bfc513..85a908e49c1 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -165,18 +165,19 @@ shapes, and unsafe byref or pinned captures are not outlined; suspensions remaining in an ordinary method are still diagnosed. The ordinary optimizer can inline an `Invoke` on a constructed delegate. -If inlining leaves an `InlineIfLambda` delegate bound to a local, directly -rewritable invocations use the outlining fallback instead. When the -construction itself contains inner callback bindings, eligible inner -callbacks are outlined before the outer delegate is marked, so each -`Await` stays inside a runtime-async method. Precomputations that capture -the delegate's inputs remain at its construction site, so a pending operation -is created once before invocation; conditional construction and repeated -invokes preserve the same captured state. A delegate that escapes or is -consumed opaquely cannot be converted. IlxGen checks each generated delegate -`Invoke` as its own method: an `Await` left there without a return marker -produces FS3918, even when the enclosing method is runtime-async. Exported -inline definitions remain unchanged. +For a residual `InlineIfLambda` delegate bound to a local, a single direct +invocation can also be inlined through simple, effect-free conditional +construction. Otherwise, directly rewritable invocations use the outlining +fallback. Inner callback bindings are processed before the outer delegate +is marked: single-use delegates can be inlined first, and residual callbacks +are outlined so each `Await` stays inside a runtime-async method. Effectful +precomputations that capture a delegate's inputs are not moved to its +invocation; outlining retains them at construction, so a pending operation +is created once and repeated invokes share captured state. A delegate that +escapes or is consumed opaquely cannot be converted. IlxGen checks each +generated delegate `Invoke` as its own method: an `Await` left there without +a return marker produces FS3918, even when the enclosing method is +runtime-async. Exported inline definitions remain unchanged. Outlining retains the callback closure and generates an async `Invoke` method for each specialized callback shape. Calls now return a `ValueTask<'T>` for diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 791963eca02..6ebdbaeb24b 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -269,6 +269,93 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) tryDelegateSignature body | _ -> None + let isTrivialValue = + function + | Expr.Const _ -> true + | Expr.Val(vref, _, _) -> vref.IsLocalRef && not vref.IsMutable && not vref.IsTypeFunction + | _ -> false + + // Construction moves to the invocation, so its evaluation must not have observable effects. + let rec inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) construction = + match construction with + | NewDelegateExpr g (_, [ _ ], _, _, _) -> + Some(MakeFSharpDelegateInvokeAndTryBetaReduce g (invokeRef, construction, invokeTy, tyargs, arg, callRange)) + | Expr.DebugPoint(point, rest) -> + inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest + |> Option.map (fun body -> Expr.DebugPoint(point, body)) + | Expr.Sequential(first, rest, kind, m) when isTrivialValue first -> + inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest + |> Option.map (fun body -> Expr.Sequential(first, body, kind, m)) + | Expr.Let((TBind(_, rhs, _)) as binding, rest, m, _) when isTrivialValue rhs -> + inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest + |> Option.map (mkLetBind m binding) + | Expr.Match(point, + matchRange, + (TDSwitch(Expr.Val(vref, _, _), [ TCase(_, TDSuccess([], _)) ], Some(TDSuccess([], _)), _) as tree), + targets, + m, + _) when vref.IsLocalRef && not vref.IsMutable && not vref.IsTypeFunction -> + let targets = + targets + |> Array.map (fun (TTarget(vals, body, flags)) -> + inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) body + |> Option.map (fun body -> TTarget(vals, body, flags))) + + if targets.Length > 0 && Array.forall Option.isSome targets then + let targets = targets |> Array.map Option.get + Some(Expr.Match(point, matchRange, tree, targets, m, tyOfExpr g targets[0].TargetExpression)) + else + None + | _ -> None + + let tryInlineDelegate callback construction continuation = + let mutable invocations = 0 + let mutable invalidUse = false + + let rwenv = + { + PreIntercept = + Some(fun rewrite expression -> + match expression with + | DelegateInvokeExpr g (invokeRef, invokeTy, tyargs, Expr.Val(vref, _, _), arg, callRange) when + valEq callback vref.Deref + -> + match inlineDelegateInvoke (invokeRef, invokeTy, tyargs, rewrite arg, callRange) construction with + | Some body -> + invocations <- invocations + 1 + Some body + | None -> + invalidUse <- true + Some expression + | Expr.Val(vref, _, _) when valEq callback vref.Deref -> + invalidUse <- true + Some expression + | _ -> None) + PreInterceptBinding = None + PostTransform = (fun _ -> None) + RewriteQuotations = false + StackGuard = StackGuard("InlineRuntimeAsyncDelegateInvocation") + } + + let continuation = RewriteExpr rwenv continuation + + let quotedUse = + ExistsExpr + (function + | Expr.Quote(body, _, _, _, _) -> + ExistsExpr + (function + | Expr.Val(vref, _, _) -> valEq callback vref.Deref + | _ -> false) + body + | _ -> false) + continuation + + if invocations = 1 && not invalidUse && not quotedUse then + Some continuation + else + None + let rec outline expr = match expr with | Expr.Let(TBind(callback, construction, point), continuation, m, _) when @@ -293,14 +380,23 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) freeVals |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) + let construction = if canCapture then outline construction else construction + + let inlined = + if canCapture && isDelegate then + tryInlineDelegate callback construction continuation + else + None + let outlinedConstruction = - if canCapture then - outlineBranches resultTy (outline construction) + if canCapture && Option.isNone inlined then + outlineBranches resultTy construction else None - match outlinedConstruction with - | Some construction -> + match inlined, outlinedConstruction with + | Some body, _ -> outline body + | None, Some construction -> let callbackTy = tyOfExpr g construction let outlined, outlinedExpr = mkCompGenLocal m "runtimeAsyncCallback" callbackTy let mutable invalidUse = false @@ -389,7 +485,7 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) expr else mkLet point m outlined construction continuation - | None -> expr + | None, None -> expr | _ -> expr | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (outline continuation) | _ -> expr diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 1da329f7f18..0ec35be5996 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1135,6 +1135,76 @@ let main _ = |> compileExeAndRun |> shouldSucceed +[] +[] +[] +let ``runtime async inlines nested single-use delegate sources`` (optimize: bool) = + let source = + FSharp """ +module NestedDelegateSource +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +type Started<'T> = delegate of unit -> 'T +let mutable constructions = 0 + +let inline compose ([] source: Started) = + Started(fun () -> source.Invoke() + 1) + +let inline run ([] code: Started) = + StateMachineHelpers.__runtimeAsyncReturn(code.Invoke()) + +[] +let execute (pending: Task) chooseFirst = + run (compose ( + if chooseFirst then + Started(fun () -> AsyncHelpers.Await pending) + else + Started(fun () -> AsyncHelpers.Await pending + 1))) + +let inline runAfterConstruction ([] code: Started) = + StateMachineHelpers.__runtimeAsyncReturn( + if constructions = 0 then failwith "Callback constructed after entering run" + code.Invoke()) + +[] +let executeWithConstruction (pending: Task) = + runAfterConstruction (compose ( + constructions <- constructions + 1 + Started(fun () -> AsyncHelpers.Await pending))) + +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let first = execute gate.Task true + let second = execute gate.Task false + let third = executeWithConstruction gate.Task + if first.IsCompleted || second.IsCompleted || third.IsCompleted || constructions <> 1 then + failwith "Delegate construction or suspension changed" + gate.SetResult 41 + if first.GetAwaiter().GetResult() <> 42 || second.GetAwaiter().GetResult() <> 43 || third.GetAwaiter().GetResult() <> 42 then + failwith "Nested delegate result changed" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + // Execution succeeds with outlining too; only the async Invoke owners distinguish the two paths. + source |> withMetadataReader (fun md -> + let asyncInvokes = + [ for handle in md.TypeDefinitions do + let ty = md.GetTypeDefinition handle + for methodHandle in ty.GetMethods() do + let method = md.GetMethodDefinition methodHandle + if md.GetString method.Name = "Invoke" && int method.ImplAttributes &&& 0x2000 <> 0 then + yield md.GetString ty.Name ] + Assert.NotEmpty asyncInvokes + Assert.All(asyncInvokes, fun name -> Assert.StartsWith("executeWithConstruction@", name))) + [] [] [] From 0a1752658056930792f8de374b2a8f7a5bd0fdf5 Mon Sep 17 00:00:00 2001 From: Jakub Majocha <1760221+majocha@users.noreply.github.com> Date: Sun, 27 Sep 2026 19:14:00 +0200 Subject: [PATCH 07/30] add comment --- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 6ebdbaeb24b..ed672319273 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -308,6 +308,7 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) None | _ -> None + // A residual Invoke can surface after ordinary optimization; try its single use before outlining. let tryInlineDelegate callback construction continuation = let mutable invocations = 0 let mutable invalidUse = false From 36b2c68df86c15e8a0b214a2e3a705fd6f781459 Mon Sep 17 00:00:00 2001 From: Jakub Majocha <1760221+majocha@users.noreply.github.com> Date: Mon, 28 Sep 2026 17:30:13 +0200 Subject: [PATCH 08/30] Refine runtime-async callback outlining Continue outlining past bindings that cannot be outlined, copy arguments duplicated into match targets, restrict sequential handling to NormalSeq, guard the outline walk against deep let chains, and share the Await builder and match/quotation helpers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 169 +++++++----------- src/Compiler/TypedTree/RuntimeAsync.fs | 9 + src/Compiler/TypedTree/RuntimeAsync.fsi | 3 + .../Language/RuntimeAsyncTests.fs | 34 ++++ 4 files changed, 108 insertions(+), 107 deletions(-) diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index ed672319273..0fb5182f3c7 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -9,7 +9,6 @@ open Internal.Utilities.Library.Extras open System.Collections.Generic open FSharp.Compiler -open FSharp.Compiler.AbstractIL.IL open FSharp.Compiler.DiagnosticsLogger open FSharp.Compiler.TcGlobals open FSharp.Compiler.Text @@ -139,6 +138,28 @@ let ShouldForceRuntimeAsyncApplication (analyzer: RuntimeAsyncAnalyzer) runtimeA | _ -> false) args) +/// Rebuilds a match with every target rewritten, or returns None if any target cannot be. +let private tryMapMatchTargets (g: TcGlobals) (point, matchRange, tree, targets: DecisionTreeTarget array, m) mapTarget = + let targets = + targets + |> Array.mapi (fun i (TTarget(vals, body, flags)) -> mapTarget i body |> Option.map (fun body -> TTarget(vals, body, flags))) + + if targets.Length > 0 && Array.forall Option.isSome targets then + let targets = Array.map Option.get targets + Some(Expr.Match(point, matchRange, tree, targets, m, tyOfExpr g targets[0].TargetExpression)) + else + None + +let private isQuotationUsing (v: Val) expr = + match expr with + | Expr.Quote(body, _, _, _, _) -> + ExistsExpr + (function + | Expr.Val(vref, _, _) -> valEq v vref.Deref + | _ -> false) + body + | _ -> false + let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) expr = let rec effectFree expr = match stripExpr expr with @@ -160,21 +181,14 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt | Expr.Let(binding, body, mLet, _), _ -> apply body (tyOfExpr g body) tyargs args m |> Option.map (mkLetBind mLet binding) - | Expr.Sequential(first, rest, kind, mSeq), _ when List.forall effectFree args -> + | Expr.Sequential(first, rest, NormalSeq, mSeq), _ -> apply rest (tyOfExpr g rest) tyargs args m - |> Option.map (fun rest -> Expr.Sequential(first, rest, kind, mSeq)) + |> Option.map (fun rest -> Expr.Sequential(first, rest, NormalSeq, mSeq)) | Expr.Match(point, matchRange, tree, targets, mMatch, _), _ when List.forall effectFree args -> - let targets = - targets - |> Array.map (fun (TTarget(vals, body, flags)) -> - apply body (tyOfExpr g body) tyargs args m - |> Option.map (fun body -> TTarget(vals, body, flags))) - - if targets.Length > 0 && Array.forall Option.isSome targets then - let targets = targets |> Array.map Option.get - Some(Expr.Match(point, matchRange, tree, targets, mMatch, tyOfExpr g targets[0].TargetExpression)) - else - None + // Each target after the first needs its own copy of any values bound by the arguments. + tryMapMatchTargets g (point, matchRange, tree, targets, mMatch) (fun i body -> + let args = if i = 0 then args else List.map (copyExpr g CloneAll) args + apply body (tyOfExpr g body) tyargs args m) | Expr.Lambda(_, _, _, [ _ ], _, _, _), first :: (_ :: _ as rest) when List.forall effectFree rest -> let reduced = MakeApplicationAndBetaReduce g (f, fty, [ tyargs ], [ first ], m) @@ -240,29 +254,19 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) | Expr.DebugPoint(point, body) -> outlineBranches resultTy body |> Option.map (fun body -> Expr.DebugPoint(point, body)) - | Expr.Sequential(first, rest, kind, m) -> + | Expr.Sequential(first, rest, NormalSeq, m) -> outlineBranches resultTy rest - |> Option.map (fun rest -> Expr.Sequential(first, rest, kind, m)) + |> Option.map (fun rest -> Expr.Sequential(first, rest, NormalSeq, m)) | Expr.Let(binding, rest, m, _) -> outlineBranches resultTy rest |> Option.map (mkLetBind m binding) | Expr.Match(point, matchRange, tree, targets, m, _) -> - let targets = - targets - |> Array.map (fun (TTarget(vals, body, flags)) -> - outlineBranches resultTy body - |> Option.map (fun body -> TTarget(vals, body, flags))) - - if targets.Length > 0 && Array.forall Option.isSome targets then - let targets = targets |> Array.map Option.get - Some(Expr.Match(point, matchRange, tree, targets, m, tyOfExpr g targets[0].TargetExpression)) - else - None + tryMapMatchTargets g (point, matchRange, tree, targets, m) (fun _ -> outlineBranches resultTy) | _ -> None let rec tryDelegateSignature expr = match expr with | NewDelegateExpr g (_, [ parameter ], body, _, _) -> Some(parameter.Type, tyOfExpr g body) | Expr.DebugPoint(_, rest) - | Expr.Sequential(_, rest, _, _) + | Expr.Sequential(_, rest, NormalSeq, _) | Expr.Let(_, rest, _, _) -> tryDelegateSignature rest | Expr.Match(_, _, _, targets, _, _) when targets.Length > 0 -> let (TTarget(_, body, _)) = targets[0] @@ -283,9 +287,9 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) | Expr.DebugPoint(point, rest) -> inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest |> Option.map (fun body -> Expr.DebugPoint(point, body)) - | Expr.Sequential(first, rest, kind, m) when isTrivialValue first -> + | Expr.Sequential(first, rest, NormalSeq, m) when isTrivialValue first -> inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest - |> Option.map (fun body -> Expr.Sequential(first, body, kind, m)) + |> Option.map (fun body -> Expr.Sequential(first, body, NormalSeq, m)) | Expr.Let((TBind(_, rhs, _)) as binding, rest, m, _) when isTrivialValue rhs -> inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest |> Option.map (mkLetBind m binding) @@ -295,17 +299,8 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) targets, m, _) when vref.IsLocalRef && not vref.IsMutable && not vref.IsTypeFunction -> - let targets = - targets - |> Array.map (fun (TTarget(vals, body, flags)) -> - inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) body - |> Option.map (fun body -> TTarget(vals, body, flags))) - - if targets.Length > 0 && Array.forall Option.isSome targets then - let targets = targets |> Array.map Option.get - Some(Expr.Match(point, matchRange, tree, targets, m, tyOfExpr g targets[0].TargetExpression)) - else - None + tryMapMatchTargets g (point, matchRange, tree, targets, m) (fun _ -> + inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange)) | _ -> None // A residual Invoke can surface after ordinary optimization; try its single use before outlining. @@ -331,6 +326,9 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) | Expr.Val(vref, _, _) when valEq callback vref.Deref -> invalidUse <- true Some expression + | Expr.Quote _ when isQuotationUsing callback expression -> + invalidUse <- true + Some expression | _ -> None) PreInterceptBinding = None PostTransform = (fun _ -> None) @@ -340,28 +338,24 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) let continuation = RewriteExpr rwenv continuation - let quotedUse = - ExistsExpr - (function - | Expr.Quote(body, _, _, _, _) -> - ExistsExpr - (function - | Expr.Val(vref, _, _) -> valEq callback vref.Deref - | _ -> false) - body - | _ -> false) - continuation - - if invocations = 1 && not invalidUse && not quotedUse then + if invocations = 1 && not invalidUse then Some continuation else None + let stackGuard = StackGuard("OutlineRuntimeAsyncCallback") + let rec outline expr = + stackGuard.Guard(fun () -> outlineCore expr) + + and outlineCore expr = match expr with | Expr.Let(TBind(callback, construction, point), continuation, m, _) when callback.InlineIfLambda && analyzer.ContainsSuspension construction -> + let keep construction = + mkLetBind m (TBind(callback, construction, point)) (outline continuation) + let canOutline = runtimeAsyncContext || (TryGetRuntimeAsyncReturn g continuation).IsSome @@ -397,8 +391,8 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) match inlined, outlinedConstruction with | Some body, _ -> outline body - | None, Some construction -> - let callbackTy = tyOfExpr g construction + | None, Some callbackConstruction -> + let callbackTy = tyOfExpr g callbackConstruction let outlined, outlinedExpr = mkCompGenLocal m "runtimeAsyncCallback" callbackTy let mutable invalidUse = false @@ -423,72 +417,33 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) let invocation = mkApps g ((outlinedExpr, callbackTy), [], [ rewrite arg ], callRange) - let resultTy = tyOfExpr g expression - let valueTaskRef = g.FindSysILTypeRef "System.Threading.Tasks.ValueTask`1" - - let awaitRef = - mkILMethRef ( - g.FindSysILTypeRef "System.Runtime.CompilerServices.AsyncHelpers", - ILCallingConv.Static, - "Await", - 1, - [ ILType.Value(mkILTySpec (valueTaskRef, [ mkILTyvarTy 0us ])) ], - mkILTyvarTy 0us - ) - - Some( - Expr.Op( - TOp.ILCall( - false, - false, - false, - false, - ValUseFlag.NormalValUse, - false, - false, - awaitRef, - [], - [ resultTy ], - [ resultTy ] - ), - [], - [ invocation ], - callRange - ) - ) + Some(mkRuntimeAsyncAwaitValueTask g callRange (tyOfExpr g expression) invocation) | None -> match expression with | Expr.Val(vref, _, _) when valEq callback vref.Deref -> invalidUse <- true Some expression + | Expr.Quote _ when isQuotationUsing callback expression -> + invalidUse <- true + Some expression | _ -> None) PreInterceptBinding = None PostTransform = (fun _ -> None) RewriteQuotations = false - StackGuard = StackGuard("OutlineRuntimeAsyncCallback") + StackGuard = stackGuard } let continuation = RewriteExpr rwenv continuation - let quotedUse = - ExistsExpr - (function - | Expr.Quote(body, _, _, _, _) -> - ExistsExpr - (function - | Expr.Val(vref, _, _) -> valEq callback vref.Deref - | _ -> false) - body - | _ -> false) - continuation - - if invalidUse || quotedUse then - expr + if invalidUse then + keep construction else - mkLet point m outlined construction continuation - | None, None -> expr - | _ -> expr + mkLet point m outlined callbackConstruction (outline continuation) + | None, None -> keep construction + | _ -> keep construction | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (outline continuation) + | Expr.DebugPoint(point, inner) -> Expr.DebugPoint(point, outline inner) + | Expr.Sequential(first, rest, NormalSeq, m) -> Expr.Sequential(first, outline rest, NormalSeq, m) | _ -> expr outline expr diff --git a/src/Compiler/TypedTree/RuntimeAsync.fs b/src/Compiler/TypedTree/RuntimeAsync.fs index ff7983cf918..1f45204e152 100644 --- a/src/Compiler/TypedTree/RuntimeAsync.fs +++ b/src/Compiler/TypedTree/RuntimeAsync.fs @@ -60,6 +60,15 @@ let IsRuntimeAsyncSuspensionExpr (g: TcGlobals) expr = let IsRuntimeAsyncBoundary (g: TcGlobals) expr = (TryGetRuntimeAsyncReturn g expr).IsSome || IsRuntimeAsyncSuspensionExpr g expr +let mkRuntimeAsyncAwaitValueTask (g: TcGlobals) m resultTy task = + let valueTaskTy = + ILType.Value(mkILTySpec (g.FindSysILTypeRef "System.Threading.Tasks.ValueTask`1", [ mkILTyvarTy 0us ])) + + let awaitRef = + mkILMethRef (g.FindSysILTypeRef runtimeAsyncHelpersTypeName, ILCallingConv.Static, "Await", 1, [ valueTaskTy ], mkILTyvarTy 0us) + + Expr.Op(TOp.ILCall(false, false, false, false, NormalValUse, false, false, awaitRef, [], [ resultTy ], [ resultTy ]), [], [ task ], m) + /// Returns true when any sub-expression matches the predicate, short-circuiting at the first match. let ExistsExpr (predicate: Expr -> bool) expr = let folder = diff --git a/src/Compiler/TypedTree/RuntimeAsync.fsi b/src/Compiler/TypedTree/RuntimeAsync.fsi index f59d8909d7a..534927a09fa 100644 --- a/src/Compiler/TypedTree/RuntimeAsync.fsi +++ b/src/Compiler/TypedTree/RuntimeAsync.fsi @@ -23,4 +23,7 @@ val IsRuntimeAsyncSuspensionExpr: g: TcGlobals -> expr: Expr -> bool val IsRuntimeAsyncBoundary: g: TcGlobals -> expr: Expr -> bool +/// Builds a call to `AsyncHelpers.Await<'T>(ValueTask<'T>)`. +val mkRuntimeAsyncAwaitValueTask: g: TcGlobals -> m: range -> resultTy: TType -> task: Expr -> Expr + val ExistsExpr: predicate: (Expr -> bool) -> expr: Expr -> bool diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 0ec35be5996..7447091a717 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1931,3 +1931,37 @@ let f : Task = |> shouldFail |> withErrorCode 39 #endif + +[] +[] +[] +let ``runtime async outlines a callback after one that cannot be outlined`` (optimize: bool) = + FSharp """ +module RuntimeAsyncOutlineAfterOpaqueCallback +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +[] +let consume (f: unit -> int) = f () + +let inline invokeBoth ([] a: unit -> int) ([] b: unit -> int) = + consume a + b() + b() + +let run (gate: Task) flag = + StateMachineHelpers.__runtimeAsyncReturn ( + invokeBoth + (let v = AsyncHelpers.Await gate in fun () -> v) + (if flag then (fun () -> AsyncHelpers.Await gate + 1) else (fun () -> 2))) + +[] +let main _ = + if (run (Task.FromResult 10) true).Result <> 32 then failwith "bad" + if (run (Task.FromResult 10) false).Result <> 14 then failwith "bad2" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed From d409d94e5cc030de2e8a479454379fec9ed0dd90 Mon Sep 17 00:00:00 2001 From: Jakub Majocha <1760221+majocha@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:28:47 +0200 Subject: [PATCH 09/30] Move runtime-async callback outlining into a lowering pass Outline suspending InlineIfLambda callbacks and prepare runtime-async bodies in a dedicated pass after the first optimization loop, so every body is prepared once in its final shape. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- docs/runtime-async.md | 49 ++++--- src/Compiler/Driver/OptimizeInputs.fs | 18 +++ src/Compiler/FSharp.Compiler.Service.fsproj | 2 + src/Compiler/Optimize/LowerRuntimeAsync.fs | 123 ++++++++++++++++++ src/Compiler/Optimize/LowerRuntimeAsync.fsi | 21 +++ src/Compiler/Optimize/Optimizer.fs | 52 +++++--- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 3 +- .../Optimize/RuntimeAsyncAnalysis.fsi | 7 +- .../Language/RuntimeAsyncTests.fs | 41 ++++++ 10 files changed, 273 insertions(+), 45 deletions(-) create mode 100644 src/Compiler/Optimize/LowerRuntimeAsync.fs create mode 100644 src/Compiler/Optimize/LowerRuntimeAsync.fsi diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index e6141489048..a2e62cc0838 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending functions and delegates once, rather than duplicating their construction at each invocation. Inline single-use delegates through effect-free conditional construction before outlining residual invokes, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending functions and delegates once, rather than duplicating their construction at each invocation. Inline single-use delegates through effect-free conditional construction before outlining residual invokes, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Outlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) ### Changed diff --git a/docs/runtime-async.md b/docs/runtime-async.md index 85a908e49c1..c643c9d67d8 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -134,11 +134,10 @@ declaration. ## Optimization `Optimizer.fs` preserves the marker application as-is, optimizing its -argument and rewriting any suspending exception handlers in that argument. -The marked expression is forced to `HasEffect = true` and `UnknownValue`, so -the optimizer never inlines, duplicates, or discards it. The marker therefore -survives optimization as an ordinary `Expr.App` node; nothing else in the -typed tree records that a method is runtime-async. +argument. The marked expression is forced to `HasEffect = true` and +`UnknownValue`, so the optimizer never inlines, duplicates, or discards it. +The marker therefore survives optimization as an ordinary `Expr.App` node; +nothing else in the typed tree records that a method is runtime-async. Inline values whose bodies contain a return marker or an `AsyncHelpers` suspension are recursively specialized at their call sites, including when @@ -148,10 +147,20 @@ is already in a runtime-async context. Ordinary inlining is tried first; applications of returned lambdas are reduced when their remaining arguments can safely move across the returned closure's construction. This handles computation-expression shapes where `Bind` returns a closure containing -`Await`, and later `Combine`/`Delay` calls apply it. +`Await`, and later `Combine`/`Delay` calls apply it. Inside a runtime-async expansion, captures +that wrap an `InlineIfLambda` lambda or delegate (`let p = (let c = e in fun +...)`) are floated above the binding so the callback can be inlined; they are +still evaluated at the same point. -If a compiler-owned `InlineIfLambda` function or delegate still contains a -suspension after inlining, a fully rewritable, single-argument callback can +## Runtime-async lowering + +`LowerRuntimeAsync.fs` runs once per file after the first optimization loop, +so it sees the final inlined shape rather than an intermediate optimizer +state, and before `LowerLocalMutables`, so mutable locals captured by an +outlined callback are promoted to reference cells. It has two steps. + +First, if a compiler-owned `InlineIfLambda` function or delegate still +contains a suspension, a fully rewritable, single-argument callback can be outlined instead. Each branch of its construction produces an F# callback returning `ValueTask<'T>`; the generated callback body contains `__runtimeAsyncReturnValueTask`, and its invocations are replaced by @@ -159,10 +168,11 @@ returning `ValueTask<'T>`; the generated callback body contains is constructed once, so conditional construction effects and state captured across repeated invocations are preserved. Only the specialized copy changes calling convention, not the exported inline definition or source signature. -This applies within an existing runtime-async body or an inline expansion -that produces one. Opaque consumers, escaping callbacks, unsupported callback -shapes, and unsafe byref or pinned captures are not outlined; suspensions -remaining in an ordinary method are still diagnosed. +This applies within a runtime-async body or sequence recipe, and to a callback +bound immediately before a runtime-async body together with the callbacks its +construction captures. Opaque consumers, escaping callbacks, unsupported +callback shapes, and unsafe byref or pinned captures are not outlined; +suspensions remaining in an ordinary method are still diagnosed. The ordinary optimizer can inline an `Invoke` on a constructed delegate. For a residual `InlineIfLambda` delegate bound to a local, a single direct @@ -183,9 +193,16 @@ Outlining retains the callback closure and generates an async `Invoke` method for each specialized callback shape. Calls now return a `ValueTask<'T>` for the enclosing method to await, trading method code size and possible allocations for preservation of callback state. It does not distribute a -continuation across conditional branches. The outlined body receives the -same byref-use checks and exception handler rewrite as a directly marked -runtime-async method. +continuation across conditional branches. + +Second, every return-marker body, including outlined callbacks, is prepared +once after outlining, innermost first: locals that cannot be preserved across +a suspension are reported (once per range across the compilation), and +suspending exception handlers are rewritten. Preparing after outlining means +an `Await` that outlining or delegate inlining moves into a handler is still +rewritten. `__runtimeAsyncSequence` recipes are not prepared here; +`LowerAsyncSeq` prepares their `MoveNextAsync` body after state-machine +conversion, which is where sequence `try`/`with` is lowered. When runtime-async specialization is forced in a debug build, the builder combinator is copied with its definition-site debug ranges remarked at the @@ -202,7 +219,7 @@ Runtime-async boundary recognition is centralized in use the shared recognizers rather than matching typed-tree shapes independently. -The optimizer uses a context-local `RuntimeAsyncAnalyzer`. It memoizes +The optimizer and the lowering pass use a context-local `RuntimeAsyncAnalyzer`. It memoizes completed expression results by reference identity and inline-value results by value stamp, with a visiting set for recursive inline-value graphs. The cache is not global: optimizer environments can provide different inline bodies, and diff --git a/src/Compiler/Driver/OptimizeInputs.fs b/src/Compiler/Driver/OptimizeInputs.fs index 0fcd75ee924..fab727d4cc5 100644 --- a/src/Compiler/Driver/OptimizeInputs.fs +++ b/src/Compiler/Driver/OptimizeInputs.fs @@ -397,6 +397,24 @@ let ApplyAllOptimizations addPhase "firstLoop" firstLoop + let runtimeAsyncReportedRanges = + System.Collections.Concurrent.ConcurrentDictionary() + + let lowerRuntimeAsync + ({ + File = file + PrevPhase = prevPhase + PrevFile = _prevFile + }: PhaseInputs) + : PhaseRes = + let file = + LowerRuntimeAsync.TransformImplFile tcGlobals runtimeAsyncReportedRanges file + + file, prevPhase + + // Must precede lowerLocalMutables: outlined callbacks become closures that can capture mutable locals. + addPhase "lowerRuntimeAsync" lowerRuntimeAsync + let lowerLocalMutables ({ File = file diff --git a/src/Compiler/FSharp.Compiler.Service.fsproj b/src/Compiler/FSharp.Compiler.Service.fsproj index 969567f4e50..42159c1ae55 100644 --- a/src/Compiler/FSharp.Compiler.Service.fsproj +++ b/src/Compiler/FSharp.Compiler.Service.fsproj @@ -471,6 +471,8 @@ + + diff --git a/src/Compiler/Optimize/LowerRuntimeAsync.fs b/src/Compiler/Optimize/LowerRuntimeAsync.fs new file mode 100644 index 00000000000..bc4a4741915 --- /dev/null +++ b/src/Compiler/Optimize/LowerRuntimeAsync.fs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +module internal FSharp.Compiler.LowerRuntimeAsync + +open System.Collections.Concurrent + +open Internal.Utilities.Library +open FSharp.Compiler +open FSharp.Compiler.DiagnosticsLogger +open FSharp.Compiler.Features +open FSharp.Compiler.RuntimeAsync +open FSharp.Compiler.RuntimeAsyncAnalysis +open FSharp.Compiler.RuntimeAsyncExceptionRewrite +open FSharp.Compiler.TcGlobals +open FSharp.Compiler.Text +open FSharp.Compiler.TypedTree +open FSharp.Compiler.TypedTreeOps + +let private isRuntimeAsyncEntry g expr = + (TryGetRuntimeAsyncReturn g expr).IsSome + || (TryGetRuntimeAsyncSequence g expr).IsSome + +let private containsRuntimeAsyncEntry g implFile = + let folder = + { ExprFolder0 with + exprIntercept = + fun _ noInterceptF acc expr -> + if acc || isRuntimeAsyncEntry g expr then + true + else + noInterceptF acc expr + } + + FoldImplFile folder false implFile + +/// Outlines suspending InlineIfLambda callbacks. `inContext` is true inside a runtime-async body or +/// sequence recipe, including nested lambdas and delegates, but not object-expression methods, which are +/// compiled as ordinary methods. +let private outlineCallbacks g implFile = + let analyzer = RuntimeAsyncAnalyzer g + let stackGuard = StackGuard("OutlineRuntimeAsyncCallbacks") + + let rec leadsToRuntimeAsyncReturn expr = + match expr with + | Expr.Let(_, rest, _, _) + | Expr.DebugPoint(_, rest) + | Expr.Sequential(_, rest, NormalSeq, _) -> leadsToRuntimeAsyncReturn rest + | _ -> (TryGetRuntimeAsyncReturn g expr).IsSome + + let rec rewriter inContext = + { + PreIntercept = Some(intercept inContext) + PreInterceptBinding = None + PostTransform = postTransform inContext + RewriteQuotations = false + StackGuard = stackGuard + } + + and rewrite inContext expr = RewriteExpr (rewriter inContext) expr + + and intercept inContext _ expr = + match expr with + | Expr.App(f, fty, tyargs, [ body ], m) when (TryGetRuntimeAsyncReturn g expr).IsSome -> + Some(Expr.App(f, fty, tyargs, [ rewrite true body ], m)) + | Expr.App(f, fty, tyargs, [ recipe ], m) when (TryGetRuntimeAsyncSequence g expr).IsSome -> + Some(Expr.App(f, fty, tyargs, [ rewrite true recipe ], m)) + // A callback constructed for a runtime-async body that follows is lowered as part of that body, + // so the suspending callbacks it captures can be inlined or outlined into it. + | Expr.Let(TBind(callback, construction, point), continuation, m, _) when + not inContext + && callback.InlineIfLambda + && leadsToRuntimeAsyncReturn continuation + -> + Some(mkLetBind m (TBind(callback, rewrite true construction, point)) (rewrite false continuation)) + | NewDelegateExpr g _ -> None + | Expr.Obj _ when inContext -> Some(rewrite false expr) + | _ -> None + + // Bottom-up: callbacks nested in the construction or continuation are already outlined. + and postTransform inContext expr = + match expr with + | Expr.Let(TBind(callback, construction, _), _, _, _) when callback.InlineIfLambda && analyzer.ContainsSuspension construction -> + Some(OutlineRuntimeAsyncCallback g analyzer inContext expr) + | _ -> None + + RewriteImplFile (rewriter false) implFile + +/// Prepares each runtime-async body once its final shape is known, innermost first. +let private prepareBodies g (reportedRanges: ConcurrentDictionary) implFile = + let prepare body = + for v in GetRuntimeAsyncNonPreservableUses g body do + if reportedRanges.TryAdd(v.Range, ()) then + errorR (Error(FSComp.SR.ilRuntimeAsyncLocalUsedAfterSuspension (RichText.mkText v.DisplayName), v.Range)) + + RewriteRuntimeAsyncExceptionHandlers g body + + RewriteImplFile + { + PreIntercept = None + PreInterceptBinding = None + PostTransform = + fun expr -> + match expr with + | Expr.App(f, fty, tyargs, [ body ], m) when (TryGetRuntimeAsyncReturn g expr).IsSome -> + Some(Expr.App(f, fty, tyargs, [ prepare body ], m)) + | _ -> None + RewriteQuotations = false + StackGuard = StackGuard("PrepareRuntimeAsyncBodies") + } + implFile + +let TransformImplFile (g: TcGlobals) reportedRanges (implFile: CheckedImplFile) = + if containsRuntimeAsyncEntry g implFile then + // Bodies inlined from an assembly compiled with the feature are still prepared. + let implFile = + if g.langVersion.SupportsFeature LanguageFeature.RuntimeAsync then + outlineCallbacks g implFile + else + implFile + + prepareBodies g reportedRanges implFile + else + implFile diff --git a/src/Compiler/Optimize/LowerRuntimeAsync.fsi b/src/Compiler/Optimize/LowerRuntimeAsync.fsi new file mode 100644 index 00000000000..e8a5e5855a3 --- /dev/null +++ b/src/Compiler/Optimize/LowerRuntimeAsync.fsi @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All Rights Reserved. See License.txt in the project root for license information. + +/// Post-optimization lowering for runtime-async bodies. +/// +/// Runs after the first optimization loop and before LowerLocalMutables. It outlines suspending +/// InlineIfLambda callbacks that could not be inlined into separate runtime-async lambdas, then +/// prepares every `__runtimeAsyncReturn*` body exactly once (non-preservable local diagnostics and +/// exception-handler rewriting). `__runtimeAsyncSequence` recipes are not prepared here: LowerAsyncSeq +/// prepares their MoveNextAsync body after state-machine conversion. +/// +/// `reportedRanges` is shared by all files of a compilation so that a diagnostic in an inline body is +/// reported once, not again at every inlining site. +module internal FSharp.Compiler.LowerRuntimeAsync + +open System.Collections.Concurrent +open FSharp.Compiler.TcGlobals +open FSharp.Compiler.Text +open FSharp.Compiler.TypedTree + +val TransformImplFile: + g: TcGlobals -> reportedRanges: ConcurrentDictionary -> implFile: CheckedImplFile -> CheckedImplFile diff --git a/src/Compiler/Optimize/Optimizer.fs b/src/Compiler/Optimize/Optimizer.fs index 1fc32b256e3..8873e6a6072 100644 --- a/src/Compiler/Optimize/Optimizer.fs +++ b/src/Compiler/Optimize/Optimizer.fs @@ -17,7 +17,6 @@ open FSharp.Compiler.DiagnosticsLogger open FSharp.Compiler.Features open FSharp.Compiler.RuntimeAsync open FSharp.Compiler.RuntimeAsyncAnalysis -open FSharp.Compiler.RuntimeAsyncExceptionRewrite open FSharp.Compiler.Text.Range open FSharp.Compiler.Syntax.PrettyNaming open FSharp.Compiler.Syntax @@ -507,9 +506,6 @@ type IncrementalOptimizationEnv = /// Indicates that the expression being optimized is the body of a runtime-async marker. runtimeAsyncContext: bool - /// Runtime async diagnostics must only be reported once across optimization passes. - runtimeAsyncReportedRanges: HashSet - } static member Empty = @@ -526,8 +522,7 @@ type IncrementalOptimizationEnv = referencedCcus = [] earlierImplFileSignatures = [] debugInlineCallSite = None - runtimeAsyncContext = false - runtimeAsyncReportedRanges = HashSet() } + runtimeAsyncContext = false } override x.ToString() = "" @@ -2728,12 +2723,37 @@ let EtaExpandUnderAppliedValBinding g expr = | EtaFloatableValLet g (bind, body, m, etaExpanded) -> floatEtaCaptures bind body m etaExpanded | _ -> expr -let PrepareRuntimeAsyncBody g (env: IncrementalOptimizationEnv) body = - for v in GetRuntimeAsyncNonPreservableUses g body do - if env.runtimeAsyncReportedRanges.Add v.Range then - errorR(Error(FSComp.SR.ilRuntimeAsyncLocalUsedAfterSuspension(RichText.mkText v.DisplayName), v.Range)) +/// `let p = (let c = e in fun x -> ...)` ~> `let c = e in let p = fun x -> ...`, p an [] +/// binding, so the lambda or delegate becomes a `CurriedLambdaValue` the optimizer can inline. The captures +/// are evaluated at the same point and are not in scope in the body. +let FloatInlineIfLambdaCaptures g expr = + let rec isFloatable rhs = + match rhs with + | Expr.Let(_, rest, _, _) + | Expr.Sequential(_, rest, NormalSeq, _) -> isFloatable rest + | _ -> + match stripDebugPoints rhs with + | Expr.Lambda _ + | Expr.TyLambda _ + | NewDelegateExpr g _ -> true + | _ -> false - RewriteRuntimeAsyncExceptionHandlers g body + match expr with + | Expr.Let(bind, body, m, _) when + bind.Var.InlineIfLambda + && (match bind.Expr with + | Expr.Let _ + | Expr.Sequential(_, _, NormalSeq, _) -> isFloatable bind.Expr + | _ -> false) + -> + let rec rebind rhs = + match rhs with + | Expr.Let(capture, rest, mLet, _) -> mkLetBind mLet capture (rebind rest) + | Expr.Sequential(first, rest, NormalSeq, mSeq) -> Expr.Sequential(first, rebind rest, NormalSeq, mSeq) + | rhs -> mkLet bind.DebugPoint m bind.Var rhs body + + rebind bind.Expr + | _ -> expr /// Optimize/analyze an expression let rec OptimizeExpr cenv (env: IncrementalOptimizationEnv) expr = @@ -2813,13 +2833,6 @@ let rec OptimizeExpr cenv (env: IncrementalOptimizationEnv) expr = | Expr.App (f, fty, tyargs, _, m) when runtimeAsyncReturn.IsSome -> let info = runtimeAsyncReturn.Value let bodyR, bodyInfo = OptimizeExpr cenv { env with runtimeAsyncContext = true } info.Body - let bodyR = - if g.langVersion.SupportsFeature LanguageFeature.RuntimeAsync then - OutlineRuntimeAsyncCallback g (RuntimeAsyncAnalyzer g) true (PrepareRuntimeAsyncBody g env) bodyR - else - bodyR - - let bodyR = PrepareRuntimeAsyncBody g env bodyR Expr.App(f, fty, tyargs, [ bodyR ], m), { bodyInfo with HasEffect = true @@ -3289,6 +3302,7 @@ and OptimizeLinearExpr cenv env expr contf = let expr = DetectAndOptimizeForEachExpression g OptimizeAllForExpressions expr let expr = if cenv.settings.ExpandStructuralValues() then ExpandStructuralBinding cenv expr else expr let expr = if cenv.settings.alwaysInline then EtaExpandUnderAppliedValBinding g expr else expr + let expr = if env.runtimeAsyncContext && cenv.settings.alwaysInline then FloatInlineIfLambdaCaptures g expr else expr let expr = stripExpr expr // Matching on 'match __resumableEntry() with ...` is really a first-class language construct which we @@ -3955,8 +3969,6 @@ and TryInlineApplication cenv env finfo (valExpr: Expr) (tyargs: TType list, arg match runtimeAsyncAnalyzer with | Some analyzer -> let result = ReduceRuntimeAsyncReturnedClosureApplications g analyzer result - let result = - OutlineRuntimeAsyncCallback g analyzer env.runtimeAsyncContext (PrepareRuntimeAsyncBody g env) result if preserveCallSite then PreserveRuntimeAsyncCallSiteDebugPoint g m result else result | None -> result else diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 0fb5182f3c7..8a6fc9f1560 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -239,13 +239,12 @@ let PreserveRuntimeAsyncCallSiteDebugPoint (g: TcGlobals) m expr = RewriteExpr rwenv expr -let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext prepareBody (expr: Expr) = +let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext (expr: Expr) = let rec outlineBranches resultTy expr = match expr with | Expr.Lambda(_, None, None, [ parameter ], body, m, _) | NewDelegateExpr g (_, [ parameter ], body, m, _) -> let marker = g.cgh__runtimeAsyncReturnValueTask_vref - let body = prepareBody body let markedBody = primMkApp (exprForValRef m marker, marker.Type) [ resultTy ] [ body ] m diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi index c1778225158..7bc64e317e1 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi @@ -25,12 +25,7 @@ val ShouldForceRuntimeAsyncApplication: bool val OutlineRuntimeAsyncCallback: - g: TcGlobals -> - analyzer: RuntimeAsyncAnalyzer -> - runtimeAsyncContext: bool -> - prepareBody: (Expr -> Expr) -> - expr: Expr -> - Expr + g: TcGlobals -> analyzer: RuntimeAsyncAnalyzer -> runtimeAsyncContext: bool -> expr: Expr -> Expr val ReduceRuntimeAsyncReturnedClosureApplications: g: TcGlobals -> analyzer: RuntimeAsyncAnalyzer -> expr: Expr -> Expr diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 7447091a717..aee0705259c 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1965,3 +1965,44 @@ let main _ = |> withOptimization optimize |> compileExeAndRun |> shouldSucceed + +[] +[] +[] +let ``runtime async prepares a body after a callback is inlined into its handler`` (optimize: bool) = + FSharp """ +module RuntimeAsyncCallbackInHandler +open System.Threading.Tasks +open Microsoft.FSharp.Core.CompilerServices + +type Started<'T> = delegate of unit -> 'T + +let inline run ([] code: Started) = + StateMachineHelpers.__runtimeAsyncReturn ( + try + failwith "boom" + with _ -> + code.Invoke()) + +let execute (pending: Task) chooseFirst = + run ( + if chooseFirst then + Started(fun () -> System.Runtime.CompilerServices.AsyncHelpers.Await pending) + else + Started(fun () -> System.Runtime.CompilerServices.AsyncHelpers.Await pending + 1)) + +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let first = execute gate.Task true + let second = execute gate.Task false + if first.IsCompleted || second.IsCompleted then failwith "Expected pending results" + gate.SetResult 41 + if first.GetAwaiter().GetResult() <> 41 || second.GetAwaiter().GetResult() <> 42 then failwith "Wrong result" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed From fe365401c64cf97476c4d265f90931f5678f9f17 Mon Sep 17 00:00:00 2001 From: Jakub Majocha <1760221+majocha@users.noreply.github.com> Date: Mon, 28 Sep 2026 19:31:55 +0200 Subject: [PATCH 10/30] Restrict runtime-async outlining tests to NETCOREAPP Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Language/RuntimeAsyncTests.fs | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index aee0705259c..b306131e42b 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1917,21 +1917,6 @@ let f (gate: Task) : Task = |> shouldFail |> withErrorCode 3918 -#else -[] -let ``runtime async intrinsic is only available in the shipped net FSharp.Core`` () = - FSharp """ -open System.Threading.Tasks -open Microsoft.FSharp.Core.CompilerServices - -let f : Task = - StateMachineHelpers.__runtimeAsyncReturn 1 -""" - |> typecheck - |> shouldFail - |> withErrorCode 39 -#endif - [] [] [] @@ -2006,3 +1991,18 @@ let main _ = |> withOptimization optimize |> compileExeAndRun |> shouldSucceed + +#else +[] +let ``runtime async intrinsic is only available in the shipped net FSharp.Core`` () = + FSharp """ +open System.Threading.Tasks +open Microsoft.FSharp.Core.CompilerServices + +let f : Task = + StateMachineHelpers.__runtimeAsyncReturn 1 +""" + |> typecheck + |> shouldFail + |> withErrorCode 39 +#endif From e8e53b0015dc4354cfb5cecbd575304c50f33ef8 Mon Sep 17 00:00:00 2001 From: Jakub Majocha <1760221+majocha@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:49:58 +0200 Subject: [PATCH 11/30] Inline branch-selected runtime-async callbacks instead of outlining Outlined callbacks ran as separate runtime-async methods, so AsyncLocal writes they made were discarded on return. A callback selected by if/match is now constructed once, recording its branch and captures in locals, and each invocation dispatches to an inlined copy of the selected body. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- docs/runtime-async.md | 86 +++++----- src/Compiler/Driver/OptimizeInputs.fs | 2 +- src/Compiler/Optimize/LowerRuntimeAsync.fs | 14 +- src/Compiler/Optimize/LowerRuntimeAsync.fsi | 4 +- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 159 +++++++++++++----- .../Optimize/RuntimeAsyncAnalysis.fsi | 2 +- src/Compiler/TypedTree/RuntimeAsync.fs | 9 - src/Compiler/TypedTree/RuntimeAsync.fsi | 3 - .../Language/RuntimeAsyncTests.fs | 116 +++++++++++-- 10 files changed, 281 insertions(+), 116 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index a2e62cc0838..fe7bf1500bd 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining eligible suspending functions and delegates once, rather than duplicating their construction at each invocation. Inline single-use delegates through effect-free conditional construction before outlining residual invokes, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Outlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and its captured values, and each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) ### Changed diff --git a/docs/runtime-async.md b/docs/runtime-async.md index c643c9d67d8..db87013e02e 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -156,53 +156,55 @@ still evaluated at the same point. `LowerRuntimeAsync.fs` runs once per file after the first optimization loop, so it sees the final inlined shape rather than an intermediate optimizer -state, and before `LowerLocalMutables`, so mutable locals captured by an -outlined callback are promoted to reference cells. It has two steps. +state, and before `LowerLocalMutables`, so mutable locals it introduces that +closures capture are promoted to reference cells. It has two steps. First, if a compiler-owned `InlineIfLambda` function or delegate still -contains a suspension, a fully rewritable, single-argument callback can -be outlined instead. Each branch of its construction produces an F# callback -returning `ValueTask<'T>`; the generated callback body contains -`__runtimeAsyncReturnValueTask`, and its invocations are replaced by -`AsyncHelpers.Await` inside the enclosing runtime-async method. The callback -is constructed once, so conditional construction effects and state captured -across repeated invocations are preserved. Only the specialized copy changes -calling convention, not the exported inline definition or source signature. -This applies within a runtime-async body or sequence recipe, and to a callback -bound immediately before a runtime-async body together with the callbacks its -construction captures. Opaque consumers, escaping callbacks, unsupported -callback shapes, and unsafe byref or pinned captures are not outlined; -suspensions remaining in an ordinary method are still diagnosed. +contains a suspension, a fully rewritable, single-argument callback is +inlined into the enclosing method. The ordinary optimizer already inlines a +callback whose construction ends in one lambda, including after `let` or +effect prefixes and at repeated invocations. What remains is a construction +that selects a lambda by branching (`if`/`match`). It is defunctionalized: +the construction runs once, in place, and each lambda in tail position is +replaced by an assignment of its branch tag and of the construction locals it +captures to mutable locals of the enclosing method. Each invocation binds its +argument once and dispatches on the tag to a copy of the selected lambda body. +Construction effects happen once, captured state is shared across +invocations, and every `Await` stays in the runtime-async method, so +`ExecutionContext` changes such as `AsyncLocal` writes behave as in source +order. Only the specialized copy changes, not the exported inline definition +or source signature. This applies within a runtime-async body or sequence +recipe, and to a callback bound immediately before a runtime-async body +together with the callbacks its construction captures. Opaque consumers, +escaping callbacks, unsupported callback shapes (including `try`/`with` in +tail position), and unsafe byref or pinned captures are not rewritten; +suspensions remaining in an ordinary method, such as a closure, are diagnosed +with FS3918. The ordinary optimizer can inline an `Invoke` on a constructed delegate. For a residual `InlineIfLambda` delegate bound to a local, a single direct invocation can also be inlined through simple, effect-free conditional -construction. Otherwise, directly rewritable invocations use the outlining -fallback. Inner callback bindings are processed before the outer delegate -is marked: single-use delegates can be inlined first, and residual callbacks -are outlined so each `Await` stays inside a runtime-async method. Effectful -precomputations that capture a delegate's inputs are not moved to its -invocation; outlining retains them at construction, so a pending operation -is created once and repeated invokes share captured state. A delegate that -escapes or is consumed opaquely cannot be converted. IlxGen checks each -generated delegate `Invoke` as its own method: an `Await` left there without -a return marker produces FS3918, even when the enclosing method is -runtime-async. Exported inline definitions remain unchanged. - -Outlining retains the callback closure and generates an async `Invoke` method -for each specialized callback shape. Calls now return a `ValueTask<'T>` for -the enclosing method to await, trading method code size and possible -allocations for preservation of callback state. It does not distribute a -continuation across conditional branches. - -Second, every return-marker body, including outlined callbacks, is prepared -once after outlining, innermost first: locals that cannot be preserved across -a suspension are reported (once per range across the compilation), and -suspending exception handlers are rewritten. Preparing after outlining means -an `Await` that outlining or delegate inlining moves into a handler is still -rewritten. `__runtimeAsyncSequence` recipes are not prepared here; -`LowerAsyncSeq` prepares their `MoveNextAsync` body after state-machine -conversion, which is where sequence `try`/`with` is lowered. +construction. Otherwise, directly rewritable invocations dispatch on the +branch tag. Inner callback bindings are processed before the outer delegate. +Effectful precomputations that capture a delegate's inputs are not moved to +its invocation, so a pending operation is created once and repeated invokes +share captured state. A delegate that escapes or is consumed opaquely cannot +be converted. IlxGen checks each generated delegate `Invoke` as its own +method: an `Await` left there without a return marker produces FS3918, even +when the enclosing method is runtime-async. Exported inline definitions remain +unchanged. + +Each invocation copies every branch body, so code size grows with the number +of invocations times the number of branches. + +Second, every return-marker body is prepared once after callbacks are inlined, +innermost first: locals that cannot be preserved across a suspension are +reported (once per range across the compilation), and suspending exception +handlers are rewritten. Preparing afterwards means an `Await` that callback +or delegate inlining moves into a handler is still rewritten. +`__runtimeAsyncSequence` recipes are not prepared here; `LowerAsyncSeq` +prepares their `MoveNextAsync` body after state-machine conversion, which is +where sequence `try`/`with` is lowered. When runtime-async specialization is forced in a debug build, the builder combinator is copied with its definition-site debug ranges remarked at the @@ -282,7 +284,7 @@ the synthesized closure and the enclosing scope share them. `InvokeFast` is not a separate runtime-async path. It is the closure-erasure shape for an indirect call with multiple arguments. Forced inlining and -eligible callback outlining happen before closure erasure; a suspension left +eligible callback inlining happen before closure erasure; a suspension left behind an opaque indirect invocation is rejected by code generation. ## Runtime capability check diff --git a/src/Compiler/Driver/OptimizeInputs.fs b/src/Compiler/Driver/OptimizeInputs.fs index fab727d4cc5..bdca940af92 100644 --- a/src/Compiler/Driver/OptimizeInputs.fs +++ b/src/Compiler/Driver/OptimizeInputs.fs @@ -412,7 +412,7 @@ let ApplyAllOptimizations file, prevPhase - // Must precede lowerLocalMutables: outlined callbacks become closures that can capture mutable locals. + // Must precede lowerLocalMutables: inlined callbacks introduce mutable locals that closures can capture. addPhase "lowerRuntimeAsync" lowerRuntimeAsync let lowerLocalMutables diff --git a/src/Compiler/Optimize/LowerRuntimeAsync.fs b/src/Compiler/Optimize/LowerRuntimeAsync.fs index bc4a4741915..6d27712b853 100644 --- a/src/Compiler/Optimize/LowerRuntimeAsync.fs +++ b/src/Compiler/Optimize/LowerRuntimeAsync.fs @@ -33,12 +33,12 @@ let private containsRuntimeAsyncEntry g implFile = FoldImplFile folder false implFile -/// Outlines suspending InlineIfLambda callbacks. `inContext` is true inside a runtime-async body or +/// Inlines suspending InlineIfLambda callbacks into the enclosing method. `inContext` is true inside a runtime-async body or /// sequence recipe, including nested lambdas and delegates, but not object-expression methods, which are /// compiled as ordinary methods. -let private outlineCallbacks g implFile = +let private inlineCallbacks g implFile = let analyzer = RuntimeAsyncAnalyzer g - let stackGuard = StackGuard("OutlineRuntimeAsyncCallbacks") + let stackGuard = StackGuard("InlineRuntimeAsyncCallbacks") let rec leadsToRuntimeAsyncReturn expr = match expr with @@ -65,7 +65,7 @@ let private outlineCallbacks g implFile = | Expr.App(f, fty, tyargs, [ recipe ], m) when (TryGetRuntimeAsyncSequence g expr).IsSome -> Some(Expr.App(f, fty, tyargs, [ rewrite true recipe ], m)) // A callback constructed for a runtime-async body that follows is lowered as part of that body, - // so the suspending callbacks it captures can be inlined or outlined into it. + // so the suspending callbacks it captures can be inlined into it. | Expr.Let(TBind(callback, construction, point), continuation, m, _) when not inContext && callback.InlineIfLambda @@ -76,11 +76,11 @@ let private outlineCallbacks g implFile = | Expr.Obj _ when inContext -> Some(rewrite false expr) | _ -> None - // Bottom-up: callbacks nested in the construction or continuation are already outlined. + // Bottom-up: callbacks nested in the construction or continuation are already inlined. and postTransform inContext expr = match expr with | Expr.Let(TBind(callback, construction, _), _, _, _) when callback.InlineIfLambda && analyzer.ContainsSuspension construction -> - Some(OutlineRuntimeAsyncCallback g analyzer inContext expr) + Some(InlineRuntimeAsyncCallback g analyzer inContext expr) | _ -> None RewriteImplFile (rewriter false) implFile @@ -114,7 +114,7 @@ let TransformImplFile (g: TcGlobals) reportedRanges (implFile: CheckedImplFile) // Bodies inlined from an assembly compiled with the feature are still prepared. let implFile = if g.langVersion.SupportsFeature LanguageFeature.RuntimeAsync then - outlineCallbacks g implFile + inlineCallbacks g implFile else implFile diff --git a/src/Compiler/Optimize/LowerRuntimeAsync.fsi b/src/Compiler/Optimize/LowerRuntimeAsync.fsi index e8a5e5855a3..abd6adff8f4 100644 --- a/src/Compiler/Optimize/LowerRuntimeAsync.fsi +++ b/src/Compiler/Optimize/LowerRuntimeAsync.fsi @@ -2,8 +2,8 @@ /// Post-optimization lowering for runtime-async bodies. /// -/// Runs after the first optimization loop and before LowerLocalMutables. It outlines suspending -/// InlineIfLambda callbacks that could not be inlined into separate runtime-async lambdas, then +/// Runs after the first optimization loop and before LowerLocalMutables. It inlines suspending +/// InlineIfLambda callbacks that the optimizer could not inline into the enclosing method, then /// prepares every `__runtimeAsyncReturn*` body exactly once (non-preservable local diagnostics and /// exception-handler rewriting). `__runtimeAsyncSequence` recipes are not prepared here: LowerAsyncSeq /// prepares their MoveNextAsync body after state-machine conversion. diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 8a6fc9f1560..626f64d0605 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -10,6 +10,7 @@ open System.Collections.Generic open FSharp.Compiler open FSharp.Compiler.DiagnosticsLogger +open FSharp.Compiler.Syntax open FSharp.Compiler.TcGlobals open FSharp.Compiler.Text open FSharp.Compiler.TypedTree @@ -239,28 +240,101 @@ let PreserveRuntimeAsyncCallSiteDebugPoint (g: TcGlobals) m expr = RewriteExpr rwenv expr -let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext (expr: Expr) = - let rec outlineBranches resultTy expr = +/// A callback construction whose branches select one of several lambdas. The construction runs once +/// and records the selected branch and the values it captures; each invocation dispatches on the branch. +type private CallbackBranches = + { + Construction: Expr + Tag: Val + Branches: (Val * Expr) list + Captures: (Val * Val) list + } + +/// Replaces each lambda in tail position of a callback construction with an assignment of its branch tag +/// and captured locals, or returns None if a tail is not a supported single-argument lambda. +let private tryDefunctionalizeCallback (g: TcGlobals) m construction = + let constructionFree = (freeInExpr CollectLocals construction).FreeLocals + let tag, _ = mkMutableCompGenLocal m "runtimeAsyncCallbackTag" g.int32_ty + let branches = ResizeArray() + let captures = Dictionary() + + let rec defunctionalize expr = match expr with | Expr.Lambda(_, None, None, [ parameter ], body, m, _) | NewDelegateExpr g (_, [ parameter ], body, m, _) -> - let marker = g.cgh__runtimeAsyncReturnValueTask_vref - - let markedBody = - primMkApp (exprForValRef m marker, marker.Type) [ resultTy ] [ body ] m - - Some(mkLambda m parameter (markedBody, tyOfExpr g markedBody)) - | Expr.DebugPoint(point, body) -> - outlineBranches resultTy body - |> Option.map (fun body -> Expr.DebugPoint(point, body)) + let captured = + (freeInExpr CollectLocals body).FreeLocals + |> Zset.elements + |> List.filter (fun v -> not (valEq v parameter) && not (Zset.contains v constructionFree)) + + if + captured + |> List.exists (fun v -> v.IsPinning || isByrefTy g v.Type || isByrefLikeTy g m v.Type) + then + None + else + let assignments = + captured + |> List.map (fun v -> + let _, hoisted = + match captures.TryGetValue v.Stamp with + | true, capture -> capture + | _ -> + let capture = v, fst (mkMutableCompGenLocal m v.LogicalName v.Type) + captures[v.Stamp] <- capture + capture + + mkValSet m (mkLocalValRef hoisted) (exprForVal m v)) + + let select = mkValSet m (mkLocalValRef tag) (mkInt32 g m branches.Count) + branches.Add(parameter, body) + Some(List.foldBack (mkCompGenSequential m) assignments select) + | Expr.DebugPoint(point, body) -> defunctionalize body |> Option.map (fun body -> Expr.DebugPoint(point, body)) | Expr.Sequential(first, rest, NormalSeq, m) -> - outlineBranches resultTy rest + defunctionalize rest |> Option.map (fun rest -> Expr.Sequential(first, rest, NormalSeq, m)) - | Expr.Let(binding, rest, m, _) -> outlineBranches resultTy rest |> Option.map (mkLetBind m binding) + | Expr.Let(binding, rest, m, _) -> defunctionalize rest |> Option.map (mkLetBind m binding) | Expr.Match(point, matchRange, tree, targets, m, _) -> - tryMapMatchTargets g (point, matchRange, tree, targets, m) (fun _ -> outlineBranches resultTy) + tryMapMatchTargets g (point, matchRange, tree, targets, m) (fun _ -> defunctionalize) | _ -> None + defunctionalize construction + |> Option.map (fun construction -> + { + Construction = construction + Tag = tag + Branches = List.ofSeq branches + Captures = List.ofSeq captures.Values + }) + +/// Inlines an invocation of a defunctionalized callback: the argument is bound once, then each branch body +/// is copied with its parameter and captured locals remapped. +let private mkCallbackDispatch (g: TcGlobals) (callback: CallbackBranches) arg resultTy m = + let argVal, _ = mkCompGenLocal m "runtimeAsyncCallbackArg" (tyOfExpr g arg) + + let captureRemap = + callback.Captures |> List.map (fun (v, hoisted) -> v, mkLocalValRef hoisted) + + let branch (parameter: Val, body) = + let remap = + { emptyRemap with + valRemap = ValMap.OfList((parameter, mkLocalValRef argVal) :: captureRemap) + } + + remapExpr g CloneAll remap body + + let rec dispatch i branches = + match branches with + | [ last ] -> branch last + | first :: rest -> + let isSelected = mkILAsmCeq g m (exprForVal m callback.Tag) (mkInt32 g m i) + + mkCond DebugPointAtBinding.NoneAtInvisible m resultTy isSelected (branch first) (dispatch (i + 1) rest) + | [] -> failwith "unreachable: callback without branches" + + mkCompGenLet m argVal arg (dispatch 0 callback.Branches) + +let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext (expr: Expr) = let rec tryDelegateSignature expr = match expr with | NewDelegateExpr g (_, [ parameter ], body, _, _) -> Some(parameter.Type, tyOfExpr g body) @@ -302,7 +376,7 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange)) | _ -> None - // A residual Invoke can surface after ordinary optimization; try its single use before outlining. + // A residual Invoke can surface after ordinary optimization; try its single use before dispatching on branches. let tryInlineDelegate callback construction continuation = let mutable invocations = 0 let mutable invalidUse = false @@ -342,20 +416,20 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) else None - let stackGuard = StackGuard("OutlineRuntimeAsyncCallback") + let stackGuard = StackGuard("InlineRuntimeAsyncCallback") - let rec outline expr = - stackGuard.Guard(fun () -> outlineCore expr) + let rec inlineCallbacks expr = + stackGuard.Guard(fun () -> inlineCallbacksCore expr) - and outlineCore expr = + and inlineCallbacksCore expr = match expr with | Expr.Let(TBind(callback, construction, point), continuation, m, _) when callback.InlineIfLambda && analyzer.ContainsSuspension construction -> let keep construction = - mkLetBind m (TBind(callback, construction, point)) (outline continuation) + mkLetBind m (TBind(callback, construction, point)) (inlineCallbacks continuation) - let canOutline = + let canInline = runtimeAsyncContext || (TryGetRuntimeAsyncReturn g continuation).IsSome let shape = @@ -367,14 +441,18 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) | _ -> None match shape with - | Some(argTy, resultTy, isDelegate) when canOutline && not (isByrefLikeTy g m argTy || isByrefLikeTy g m resultTy) -> + | Some(argTy, resultTy, isDelegate) when canInline && not (isByrefLikeTy g m argTy || isByrefLikeTy g m resultTy) -> let freeVals = (freeInExpr CollectLocals construction).FreeLocals let canCapture = freeVals |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) - let construction = if canCapture then outline construction else construction + let construction = + if canCapture then + inlineCallbacks construction + else + construction let inlined = if canCapture && isDelegate then @@ -382,17 +460,15 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) else None - let outlinedConstruction = + let callbackBranches = if canCapture && Option.isNone inlined then - outlineBranches resultTy construction + tryDefunctionalizeCallback g m construction else None - match inlined, outlinedConstruction with - | Some body, _ -> outline body - | None, Some callbackConstruction -> - let callbackTy = tyOfExpr g callbackConstruction - let outlined, outlinedExpr = mkCompGenLocal m "runtimeAsyncCallback" callbackTy + match inlined, callbackBranches with + | Some body, _ -> inlineCallbacks body + | None, Some callbackBranches -> let mutable invalidUse = false let rwenv = @@ -413,10 +489,7 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) match call with | Some(arg, callRange) -> - let invocation = - mkApps g ((outlinedExpr, callbackTy), [], [ rewrite arg ], callRange) - - Some(mkRuntimeAsyncAwaitValueTask g callRange (tyOfExpr g expression) invocation) + Some(mkCallbackDispatch g callbackBranches (rewrite arg) (tyOfExpr g expression) callRange) | None -> match expression with | Expr.Val(vref, _, _) when valEq callback vref.Deref -> @@ -437,15 +510,23 @@ let OutlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) if invalidUse then keep construction else - mkLet point m outlined callbackConstruction (outline continuation) + let construction = + match point with + | DebugPointAtBinding.Yes point -> mkDebugPoint point callbackBranches.Construction + | _ -> callbackBranches.Construction + + let body = mkCompGenSequential m construction (inlineCallbacks continuation) + + (callbackBranches.Tag :: List.map snd callbackBranches.Captures, body) + ||> List.foldBack (fun v body -> mkCompGenLet m v (mkDefault (m, v.Type)) body) | None, None -> keep construction | _ -> keep construction - | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (outline continuation) - | Expr.DebugPoint(point, inner) -> Expr.DebugPoint(point, outline inner) - | Expr.Sequential(first, rest, NormalSeq, m) -> Expr.Sequential(first, outline rest, NormalSeq, m) + | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (inlineCallbacks continuation) + | Expr.DebugPoint(point, inner) -> Expr.DebugPoint(point, inlineCallbacks inner) + | Expr.Sequential(first, rest, NormalSeq, m) -> Expr.Sequential(first, inlineCallbacks rest, NormalSeq, m) | _ -> expr - outline expr + inlineCallbacks expr type private RuntimeAsyncFlowSummary = { diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi index 7bc64e317e1..31db4214b68 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi @@ -24,7 +24,7 @@ val ShouldForceRuntimeAsyncApplication: args: Expr list -> bool -val OutlineRuntimeAsyncCallback: +val InlineRuntimeAsyncCallback: g: TcGlobals -> analyzer: RuntimeAsyncAnalyzer -> runtimeAsyncContext: bool -> expr: Expr -> Expr val ReduceRuntimeAsyncReturnedClosureApplications: g: TcGlobals -> analyzer: RuntimeAsyncAnalyzer -> expr: Expr -> Expr diff --git a/src/Compiler/TypedTree/RuntimeAsync.fs b/src/Compiler/TypedTree/RuntimeAsync.fs index 1f45204e152..ff7983cf918 100644 --- a/src/Compiler/TypedTree/RuntimeAsync.fs +++ b/src/Compiler/TypedTree/RuntimeAsync.fs @@ -60,15 +60,6 @@ let IsRuntimeAsyncSuspensionExpr (g: TcGlobals) expr = let IsRuntimeAsyncBoundary (g: TcGlobals) expr = (TryGetRuntimeAsyncReturn g expr).IsSome || IsRuntimeAsyncSuspensionExpr g expr -let mkRuntimeAsyncAwaitValueTask (g: TcGlobals) m resultTy task = - let valueTaskTy = - ILType.Value(mkILTySpec (g.FindSysILTypeRef "System.Threading.Tasks.ValueTask`1", [ mkILTyvarTy 0us ])) - - let awaitRef = - mkILMethRef (g.FindSysILTypeRef runtimeAsyncHelpersTypeName, ILCallingConv.Static, "Await", 1, [ valueTaskTy ], mkILTyvarTy 0us) - - Expr.Op(TOp.ILCall(false, false, false, false, NormalValUse, false, false, awaitRef, [], [ resultTy ], [ resultTy ]), [], [ task ], m) - /// Returns true when any sub-expression matches the predicate, short-circuiting at the first match. let ExistsExpr (predicate: Expr -> bool) expr = let folder = diff --git a/src/Compiler/TypedTree/RuntimeAsync.fsi b/src/Compiler/TypedTree/RuntimeAsync.fsi index 534927a09fa..f59d8909d7a 100644 --- a/src/Compiler/TypedTree/RuntimeAsync.fsi +++ b/src/Compiler/TypedTree/RuntimeAsync.fsi @@ -23,7 +23,4 @@ val IsRuntimeAsyncSuspensionExpr: g: TcGlobals -> expr: Expr -> bool val IsRuntimeAsyncBoundary: g: TcGlobals -> expr: Expr -> bool -/// Builds a call to `AsyncHelpers.Await<'T>(ValueTask<'T>)`. -val mkRuntimeAsyncAwaitValueTask: g: TcGlobals -> m: range -> resultTy: TType -> task: Expr -> Expr - val ExistsExpr: predicate: (Expr -> bool) -> expr: Expr -> bool diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index b306131e42b..3fc25ac5a87 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1193,7 +1193,8 @@ let main _ = |> compileExeAndRun |> shouldSucceed - // Execution succeeds with outlining too; only the async Invoke owners distinguish the two paths. + // The construction in executeWithConstruction precedes its runtime-async body, which therefore starts as + // a closure; callbacks are inlined, so that closure is the only async Invoke. source |> withMetadataReader (fun md -> let asyncInvokes = [ for handle in md.TypeDefinitions do @@ -1202,8 +1203,8 @@ let main _ = let method = md.GetMethodDefinition methodHandle if md.GetString method.Name = "Invoke" && int method.ImplAttributes &&& 0x2000 <> 0 then yield md.GetString ty.Name ] - Assert.NotEmpty asyncInvokes - Assert.All(asyncInvokes, fun name -> Assert.StartsWith("executeWithConstruction@", name))) + let asyncInvoke = Assert.Single asyncInvokes + Assert.StartsWith("executeWithConstruction@", asyncInvoke)) [] [] @@ -1271,7 +1272,7 @@ let main _ = [] [] [] -let ``runtime async outlines repeated conditional delegate invocations`` (optimize: bool) = +let ``runtime async inlines repeated conditional delegate invocations`` (optimize: bool) = let library = FSharp """ module ImportedDelegateCallbacks @@ -1334,7 +1335,7 @@ let main _ = [] [] [] -let ``runtime async does not outline delegates passed to opaque consumers`` (optimize: bool) = +let ``runtime async does not rewrite delegates passed to opaque consumers`` (optimize: bool) = FSharp """ module RuntimeAsyncOpaqueDelegate @@ -1670,7 +1671,7 @@ let main _ = [] [] [] -let ``runtime async outlines a stateful conditional callback from an imported inline`` (optimize: bool) = +let ``runtime async inlines a stateful conditional callback from an imported inline`` (optimize: bool) = let library = FSharp """ module RuntimeAsyncCallbackLibrary @@ -1731,9 +1732,9 @@ let main _ = [] [] [] -let ``runtime async outlined callbacks preserve cleanup after suspension`` (optimize: bool) = +let ``runtime async inlined conditional callbacks preserve cleanup after suspension`` (optimize: bool) = FSharp """ -module RuntimeAsyncOutlinedCleanupTest +module RuntimeAsyncConditionalCallbackCleanupTest open System.Collections.Generic open System.Threading.Tasks @@ -1785,7 +1786,7 @@ let main _ = [] [] [] -let ``runtime async does not outline opaque callback consumers`` (optimize: bool) = +let ``runtime async does not rewrite opaque callback consumers`` (optimize: bool) = FSharp """ module RuntimeAsyncOpaqueCallbackTest @@ -1920,9 +1921,9 @@ let f (gate: Task) : Task = [] [] [] -let ``runtime async outlines a callback after one that cannot be outlined`` (optimize: bool) = +let ``runtime async inlines a callback after one that cannot be inlined`` (optimize: bool) = FSharp """ -module RuntimeAsyncOutlineAfterOpaqueCallback +module RuntimeAsyncInlineAfterOpaqueCallback open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices @@ -1951,6 +1952,99 @@ let main _ = |> compileExeAndRun |> shouldSucceed +[] +[] +[] +let ``runtime async inlines pattern-bound captures of a branch-selected callback`` (optimize: bool) = + FSharp """ +module RuntimeAsyncPatternCapturedCallback +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let mutable constructions = 0 + +let inline twice ([] f: int -> int) = f 1 + f 2 + +let run (gate: Task) (choice: int option) = + StateMachineHelpers.__runtimeAsyncReturn ( + twice ( + constructions <- constructions + 1 + match choice with + | Some offset -> + let scaled = offset * 10 + fun x -> AsyncHelpers.Await gate + scaled + x + | None -> fun x -> x)) + +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let pending = run gate.Task (Some 4) + if pending.IsCompleted then failwith "Expected pending result" + gate.SetResult 100 + if pending.GetAwaiter().GetResult() <> 283 then failwith "bad some" + if (run gate.Task None).Result <> 3 then failwith "bad none" + if constructions <> 2 then failwith "construction repeated" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + +[] +[] +[] +let ``runtime async callback selected by a branch keeps AsyncLocal changes`` (optimize: bool) = + FSharp """ +module RuntimeAsyncBranchCallbackAsyncLocal +open System.Threading +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let context = AsyncLocal() + +let inline invoke ([] f: unit -> string) = + let seen = f () + seen + "/" + context.Value + +let run (gate: Task) flag = + StateMachineHelpers.__runtimeAsyncReturn ( + context.Value <- "outer" + invoke ( + if flag then + (let captured = context.Value + ":" + fun () -> + let before = context.Value + AsyncHelpers.Await gate |> ignore + let after = context.Value + context.Value <- "inner" + captured + before + "-" + after) + else + (fun () -> "none"))) + +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let pending = run gate.Task true + if pending.IsCompleted then failwith "Expected pending result" + gate.SetResult 1 + let result = pending.GetAwaiter().GetResult() + if result <> "outer:outer-outer/inner" then failwithf "Unexpected: %s" result + let completed = (run (Task.FromResult 1) true).Result + if completed <> "outer:outer-outer/inner" then failwithf "Unexpected completed: %s" completed + if (run (Task.FromResult 1) false).Result <> "none/outer" then failwith "bad2" + if not (isNull context.Value) then failwith "AsyncLocal leaked to caller" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + [] [] [] From 94c8df45dc27e56589e0897e9f1af8a4d41a1c32 Mon Sep 17 00:00:00 2001 From: Jakub Majocha <1760221+majocha@users.noreply.github.com> Date: Mon, 28 Sep 2026 21:54:59 +0200 Subject: [PATCH 12/30] Limit copies of branch-selected runtime-async callbacks Every invocation copies every branch body. Skip the rewrite when the copies would exceed 2000 expression nodes, leaving the callback as a closure so a remaining suspension is reported as FS3918. Add regression tests for callbacks constructed in a loop and nested branch-selected callbacks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/runtime-async.md | 4 +- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 19 +++- .../Language/RuntimeAsyncTests.fs | 95 +++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/docs/runtime-async.md b/docs/runtime-async.md index db87013e02e..3ef1e72292d 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -195,7 +195,9 @@ when the enclosing method is runtime-async. Exported inline definitions remain unchanged. Each invocation copies every branch body, so code size grows with the number -of invocations times the number of branches. +of invocations times the number of branches. When the copies would exceed +2000 expression nodes, the callback is left as a closure and a suspension in +it is reported as FS3918. Second, every return-marker body is prepared once after callbacks are inlined, innermost first: locals that cannot be preserved across a suspension are diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 626f64d0605..d498719327f 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -307,6 +307,18 @@ let private tryDefunctionalizeCallback (g: TcGlobals) m construction = Captures = List.ofSeq captures.Values }) +/// Every invocation copies every branch body, so the rewrite is skipped when the copies would exceed this many +/// expression nodes; the callback then stays a closure and a suspension left in it is reported as FS3918. +let private maxInlinedCallbackCopySize = 2000 + +let private exprNodeCount expr = + let folder = + { ExprFolder0 with + exprIntercept = fun _ noInterceptF count expr -> noInterceptF (count + 1) expr + } + + FoldExpr folder 0 expr + /// Inlines an invocation of a defunctionalized callback: the argument is bound once, then each branch body /// is copied with its parameter and captured locals remapped. let private mkCallbackDispatch (g: TcGlobals) (callback: CallbackBranches) arg resultTy m = @@ -470,6 +482,7 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r | Some body, _ -> inlineCallbacks body | None, Some callbackBranches -> let mutable invalidUse = false + let mutable invocations = 0 let rwenv = { @@ -489,6 +502,7 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r match call with | Some(arg, callRange) -> + invocations <- invocations + 1 Some(mkCallbackDispatch g callbackBranches (rewrite arg) (tyOfExpr g expression) callRange) | None -> match expression with @@ -507,7 +521,10 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r let continuation = RewriteExpr rwenv continuation - if invalidUse then + let copySize = + invocations * List.sumBy (snd >> exprNodeCount) callbackBranches.Branches + + if invalidUse || copySize > maxInlinedCallbackCopySize then keep construction else let construction = diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 3fc25ac5a87..2d6d428d0cc 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1993,6 +1993,101 @@ let main _ = |> compileExeAndRun |> shouldSucceed +[] +[] +[] +let ``runtime async reconstructs a branch-selected callback on each loop iteration`` (optimize: bool) = + FSharp """ +module RuntimeAsyncCallbackInLoop +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let inline invoke ([] f: int -> int) = f 1 + f 2 + +let run (gate: Task) = + StateMachineHelpers.__runtimeAsyncReturn ( + let mutable total = 0 + for i in 0 .. 2 do + total <- + total + + invoke ( + if i % 2 = 0 then + (let k = i * 100 in fun x -> AsyncHelpers.Await gate + k + x) + else + (fun x -> x)) + total) + +[] +let main _ = + let result = (run (Task.FromResult 1000)).Result + if result <> 4409 then failwithf "Unexpected: %d" result + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + +[] +[] +[] +let ``runtime async inlines nested branch-selected callbacks`` (optimize: bool) = + FSharp """ +module RuntimeAsyncNestedBranchCallbacks +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let inline invoke ([] f: int -> int) = f 1 + f 2 + f 3 + +let inline pick flag ([] a: int -> int) ([] b: int -> int) = + invoke (if flag then (fun x -> a x + b x) else (fun x -> b (a x))) + +let run (gate: Task) f1 f2 = + StateMachineHelpers.__runtimeAsyncReturn ( + pick + f1 + (if f2 then (fun x -> AsyncHelpers.Await gate + x) else (fun x -> x * 2)) + (if f2 then (fun x -> x + 1) else (fun x -> AsyncHelpers.Await gate - x))) + +[] +let main _ = + let r a b = (run (Task.FromResult 10) a b).Result + let results = r true true, r true false, r false true, r false false + if results <> (45, 36, 39, 18) then failwithf "Unexpected: %A" results + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + +[] +let ``runtime async rejects a branch-selected callback too large to copy into every invocation`` () = + let calls = List.init 40 (sprintf "f %d") |> String.concat " + " + let terms = List.init 30 (sprintf "x * %d") |> String.concat " + " + + FSharp $""" +module RuntimeAsyncOversizedCallback +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let inline invoke ([] f: int -> int) = {calls} + +let run (gate: Task) flag = + StateMachineHelpers.__runtimeAsyncReturn ( + invoke (if flag then (fun x -> AsyncHelpers.Await gate + {terms}) else (fun x -> x))) +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compile + |> shouldFail + |> withErrorCode 3918 + [] [] [] From 68ce5fac2c11789b2fdf3b8ddf88ba67015fd407 Mon Sep 17 00:00:00 2001 From: Jakub Majocha <1760221+majocha@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:08:47 +0200 Subject: [PATCH 13/30] share captured mutables --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 65 ++++++++++++++++--- .../Language/RuntimeAsyncTests.fs | 54 +++++++++++++++ 3 files changed, 112 insertions(+), 9 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index fe7bf1500bd..4add8f8ae9f 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and its captured values, and each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) ### Changed diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index d498719327f..7460551235b 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -250,6 +250,47 @@ type private CallbackBranches = Captures: (Val * Val) list } +/// A captured mutable keeps a single shared home: its binding becomes an assignment to the hoisted local and +/// every other use is redirected to it, so writes made by the inlined callback stay visible to other closures. +/// Returns None if a captured mutable is not bound by a let in the construction. +let private tryShareMutableCallbackCaptures (g: TcGlobals) (mutableCaptures: (Val * Val) list) construction = + if mutableCaptures.IsEmpty then + Some construction + else + let hoistedOf = + ValMap.OfList(mutableCaptures |> List.map (fun (v, hoisted) -> v, mkLocalValRef hoisted)) + + let rewritten = HashSet() + + let rwenv = + { + PreIntercept = + Some(fun rewrite expression -> + match expression with + | Expr.Let(TBind(v, rhs, point), body, m, _) when hoistedOf.ContainsVal v -> + rewritten.Add v.Stamp |> ignore + let assignment = mkValSet m hoistedOf[v] (rewrite rhs) + + let assignment = + match point with + | DebugPointAtBinding.Yes point -> mkDebugPoint point assignment + | _ -> assignment + + Some(mkCompGenSequential m assignment (rewrite body)) + | _ -> None) + PreInterceptBinding = None + PostTransform = (fun _ -> None) + RewriteQuotations = false + StackGuard = StackGuard("RewriteRuntimeAsyncMutableCaptures") + } + + let construction = RewriteExpr rwenv construction + + if rewritten.Count = mutableCaptures.Length then + Some(remapExpr g CloneAll { emptyRemap with valRemap = hoistedOf } construction) + else + None + /// Replaces each lambda in tail position of a callback construction with an assignment of its branch tag /// and captured locals, or returns None if a tail is not a supported single-argument lambda. let private tryDefunctionalizeCallback (g: TcGlobals) m construction = @@ -284,7 +325,11 @@ let private tryDefunctionalizeCallback (g: TcGlobals) m construction = captures[v.Stamp] <- capture capture - mkValSet m (mkLocalValRef hoisted) (exprForVal m v)) + if v.IsMutable then + None + else + Some(mkValSet m (mkLocalValRef hoisted) (exprForVal m v))) + |> List.choose id let select = mkValSet m (mkLocalValRef tag) (mkInt32 g m branches.Count) branches.Add(parameter, body) @@ -299,13 +344,17 @@ let private tryDefunctionalizeCallback (g: TcGlobals) m construction = | _ -> None defunctionalize construction - |> Option.map (fun construction -> - { - Construction = construction - Tag = tag - Branches = List.ofSeq branches - Captures = List.ofSeq captures.Values - }) + |> Option.bind (fun construction -> + let captures = List.ofSeq captures.Values + + tryShareMutableCallbackCaptures g (captures |> List.filter (fun (v, _) -> v.IsMutable)) construction + |> Option.map (fun construction -> + { + Construction = construction + Tag = tag + Branches = List.ofSeq branches + Captures = captures + })) /// Every invocation copies every branch body, so the rewrite is skipped when the copies would exceed this many /// expression nodes; the callback then stays a closure and a suspension left in it is reported as FS3918. diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 2d6d428d0cc..9a1b38f7ce8 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1668,6 +1668,60 @@ let main _ = |> compileExeAndRun |> shouldSucceed +[] +[] +[] +let ``runtime async conditional callbacks share mutable captures with closures`` (optimize: bool) = + FSharp """ +module RuntimeAsyncConditionalMutableCapture + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let mutable observe = fun () -> -1 + +let inline invoke ([] callback: unit -> int) = + StateMachineHelpers.__runtimeAsyncReturn (callback ()) + +let run flag = + invoke ( + if flag then + let mutable count = 0 + observe <- fun () -> count + fun () -> + AsyncHelpers.Await(Task.FromResult 0) |> ignore + count <- count + 1 + count + else + fun () -> 0) + +let runMultiple flag = + invoke ( + if flag then + let mutable count = 0 + let mutable additional = 0 + observe <- fun () -> count + additional + fun () -> + AsyncHelpers.Await(Task.FromResult 0) |> ignore + count <- count + 1 + additional <- additional + 1 + count + additional + else + fun () -> 0) + +[] +let main _ = + let actual = (run true).Result, observe () + let multiple = (runMultiple true).Result, observe () + if actual = (1, 1) && multiple = (2, 2) then 0 else 1 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + [] [] [] From 8ffc76e50f8f76658a93fb47370ee1ebad491e99 Mon Sep 17 00:00:00 2001 From: Jakub Majocha <1760221+majocha@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:25:35 +0200 Subject: [PATCH 14/30] preserve callback debug points --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- src/Compiler/Optimize/Optimizer.fs | 3 +- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 1 + .../Language/RuntimeAsyncTests.fs | 31 +++++++++++++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 4add8f8ae9f..ac3a85198d7 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Inlined callback statements keep their own debug points. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) ### Changed diff --git a/src/Compiler/Optimize/Optimizer.fs b/src/Compiler/Optimize/Optimizer.fs index 8873e6a6072..baec6cf7e9f 100644 --- a/src/Compiler/Optimize/Optimizer.fs +++ b/src/Compiler/Optimize/Optimizer.fs @@ -4101,8 +4101,9 @@ and TryInlineApplication cenv env finfo (valExpr: Expr) (tyargs: TType list, arg specLambda specLambdaR + // A local lambda is user code and keeps its debug points; an inline function's body belongs at the call site. let specLambdaR = - if mustInlineRuntimeAsync then + if mustInlineRuntimeAsync && vref.IsCompiledAsTopLevel then remarkExpr m specLambdaR else specLambdaR diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 7460551235b..3a241cf16f1 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -179,6 +179,7 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt | _ -> false -> apply inner fty tyargs args m + | Expr.DebugPoint(point, inner), _ -> apply inner fty tyargs args m |> Option.map (fun r -> Expr.DebugPoint(point, r)) | Expr.Let(binding, body, mLet, _), _ -> apply body (tyOfExpr g body) tyargs args m |> Option.map (mkLetBind mLet binding) diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 9a1b38f7ce8..3d4c62efd1c 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1430,6 +1430,37 @@ let second () = VerifyRuntimeAsyncMethodSequencePointsInSource("RuntimeAsyncEnumerableDebug.fs", 12, 14) ] +[] +[] +[] +let ``runtime async inlined InlineIfLambda callback keeps its statement debug points`` (optimize: bool) = + let statementPoints = [ Line 13, Col 9, Line 13, Col 45; Line 14, Col 9, Line 14, Col 27; Line 15, Col 9, Line 15, Col 18 ] + let callSitePoint = [ Line 12, Col 5, Line 15, Col 19 ] + + FSharp """ +module CallbackDebugPoints + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let inline run ([] body: unit -> int) : Task = + StateMachineHelpers.__runtimeAsyncReturn (body ()) + +let work (ready: Task) = + run (fun () -> + let value = AsyncHelpers.Await ready + printfn "%d" value + value + 1) +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withPortablePdb + |> withOptimization optimize + |> compile + |> shouldSucceed + |> verifyPdb [ VerifyMethodSequencePoints("work", (if optimize then statementPoints else callSitePoint @ statementPoints)) ] + [] let ``runtime async suspension in exception region executes`` () = Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTasksAsyncDisposalException.fs") From e08541e2e232e3c9b5236a483e1cb9ebfb103704 Mon Sep 17 00:00:00 2001 From: Jakub Majocha <1760221+majocha@users.noreply.github.com> Date: Mon, 5 Oct 2026 17:02:17 +0200 Subject: [PATCH 15/30] Scope runtime async debug point preservation Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/Compiler/Optimize/Optimizer.fs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Compiler/Optimize/Optimizer.fs b/src/Compiler/Optimize/Optimizer.fs index baec6cf7e9f..9f7f4695623 100644 --- a/src/Compiler/Optimize/Optimizer.fs +++ b/src/Compiler/Optimize/Optimizer.fs @@ -4101,9 +4101,9 @@ and TryInlineApplication cenv env finfo (valExpr: Expr) (tyargs: TType list, arg specLambda specLambdaR - // A local lambda is user code and keeps its debug points; an inline function's body belongs at the call site. + // InlineIfLambda callbacks keep their source points; forced inline helpers belong at the call site. let specLambdaR = - if mustInlineRuntimeAsync && vref.IsCompiledAsTopLevel then + if mustInlineRuntimeAsync && not vref.InlineIfLambda then remarkExpr m specLambdaR else specLambdaR From a77827c7a5075c8088b4112806d35045628ed3dd Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Wed, 7 Oct 2026 16:00:54 +0200 Subject: [PATCH 16/30] Preserve mutable runtime-async capture debugger metadata --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 8 ++- .../Language/RuntimeAsyncTests.fs | 56 +++++++++++++++++++ 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 4b29edbb931..f24a5635997 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Inlined callback statements keep their own debug points. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Inlined callback statements keep their own debug points, and captured mutable locals remain visible in the debugger during callback construction. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * Preserve tupled lambda argument groups when reducing state-machine applications, avoiding invalid argument substitutions and runtime failures with inline formatters. ([Issue #20675](https://github.com/dotnet/fsharp/issues/20675), [PR #20700](https://github.com/dotnet/fsharp/pull/20700)) diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 3a241cf16f1..9a964fa2db4 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -322,7 +322,13 @@ let private tryDefunctionalizeCallback (g: TcGlobals) m construction = match captures.TryGetValue v.Stamp with | true, capture -> capture | _ -> - let capture = v, fst (mkMutableCompGenLocal m v.LogicalName v.Type) + let hoisted = + if v.IsMutable then + Construct.NewModifiedVal id v + else + fst (mkMutableCompGenLocal m v.LogicalName v.Type) + + let capture = v, hoisted captures[v.Stamp] <- capture capture diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 3d4c62efd1c..330f2df9787 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -4,6 +4,7 @@ open Xunit open FSharp.Test open FSharp.Test.Compiler open System.IO +open System.Reflection.Metadata open System.Text.RegularExpressions let private runtimeAsyncSource = """ @@ -1461,6 +1462,61 @@ let work (ready: Task) = |> shouldSucceed |> verifyPdb [ VerifyMethodSequencePoints("work", (if optimize then statementPoints else callSitePoint @ statementPoints)) ] +[] +let ``runtime async conditional callback keeps mutable capture visible during construction`` () = + let result = + FSharp """module MissingMutableLocal + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let inline invoke ([] callback: unit -> int) : Task = + StateMachineHelpers.__runtimeAsyncReturn (callback ()) + +let run flag (ready: Task) = + invoke ( + if flag then + let mutable count = 0 + printfn "%d" count + fun () -> + let value = AsyncHelpers.Await ready + count <- count + value + count + else + fun () -> 0) +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withPortablePdb + |> withNoOptimize + |> compile + |> shouldSucceed + + use stream = File.OpenRead(Path.ChangeExtension(result.OutputPath.Value, "pdb")) + use provider = MetadataReaderProvider.FromPortablePdbStream stream + let pdb = provider.GetMetadataReader() + + result |> withMetadataReader (fun md -> + let method = + md.MethodDefinitions + |> Seq.filter (fun handle -> md.GetString(md.GetMethodDefinition(handle).Name) = "run") + |> Assert.Single + let point = + pdb.GetMethodDebugInformation(method).GetSequencePoints() + |> Seq.filter (fun point -> not point.IsHidden && point.StartLine = 14) + |> Assert.Single + let local = + [ for handle in pdb.GetLocalScopes method do + let scope = pdb.GetLocalScope handle + if scope.StartOffset <= point.Offset && point.Offset < scope.EndOffset then + for local in scope.GetLocalVariables() do + let variable = pdb.GetLocalVariable local + if pdb.GetString variable.Name = "count" then + yield variable ] + |> Assert.Single + Assert.Equal(LocalVariableAttributes.None, local.Attributes)) + [] let ``runtime async suspension in exception region executes`` () = Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTasksAsyncDisposalException.fs") From 52eb3178f914ec94eb195fae0253eb7f294736bd Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Thu, 8 Oct 2026 13:54:57 +0200 Subject: [PATCH 17/30] Reduce runtime-async returned closures across recursive bindings --- .../.FSharp.Compiler.Service/11.0.200.md | 1 + src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 5 +- .../Language/RuntimeAsyncTests.fs | 51 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index f24a5635997..4ec79978330 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -8,6 +8,7 @@ * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) * Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Inlined callback statements keep their own debug points, and captured mutable locals remain visible in the debugger during callback construction. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Fix spurious FS3918 errors when a runtime-async callback defines recursive local functions before returning a suspending closure. * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * Preserve tupled lambda argument groups when reducing state-machine applications, avoiding invalid argument substitutions and runtime failures with inline formatters. ([Issue #20675](https://github.com/dotnet/fsharp/issues/20675), [PR #20700](https://github.com/dotnet/fsharp/pull/20700)) diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 9a964fa2db4..27f1b446bd6 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -183,6 +183,9 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt | Expr.Let(binding, body, mLet, _), _ -> apply body (tyOfExpr g body) tyargs args m |> Option.map (mkLetBind mLet binding) + | Expr.LetRec(bindings, body, mLet, _), _ -> + apply body (tyOfExpr g body) tyargs args m + |> Option.map (mkLetRecBinds mLet bindings) | Expr.Sequential(first, rest, NormalSeq, mSeq), _ -> apply rest (tyOfExpr g rest) tyargs args m |> Option.map (fun rest -> Expr.Sequential(first, rest, NormalSeq, mSeq)) @@ -211,7 +214,7 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt PostTransform = (fun expression -> match expression with - | Expr.App((Expr.Lambda _ | Expr.Let _ | Expr.Match _ | Expr.DebugPoint _) as f, fty, tyargs, args, m) when + | Expr.App((Expr.Lambda _ | Expr.Let _ | Expr.LetRec _ | Expr.Match _ | Expr.DebugPoint _) as f, fty, tyargs, args, m) when not args.IsEmpty && analyzer.ContainsSuspension expression -> apply f fty tyargs args m diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 330f2df9787..6f43faf8c8e 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -553,6 +553,57 @@ let main _ = |> compileExeAndRun |> shouldSucceed +[] +[] +[] +[] +[] +let ``runtime async fuses returned closures across recursive bindings`` (optimize: bool, nestedRuntimeAsync: bool) = + let loopBody = + if nestedRuntimeAsync then + "__runtimeAsyncReturn (if count = 0 then AsyncHelpers.Await ready else AsyncHelpers.Await (loop (count - 1)))" + else + "if count = 0 then ready else loop (count - 1)" + + FSharp $""" +module RuntimeAsyncRecursiveReturnedClosureTest + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers + +let mutable constructions = 0 +let mutable invocations = 0 + +let inline runBody ([] body: unit -> unit -> int) = + __runtimeAsyncReturn (body () ()) + +let run (ready: Task) = + runBody (fun () -> + constructions <- constructions + 1 + let rec loop count : Task = + {loopBody} + fun () -> + invocations <- invocations + 1 + AsyncHelpers.Await (loop 2)) + +[] +let main _ = + let ready = TaskCompletionSource() + let result = run ready.Task + if result.IsCompleted || constructions <> 1 || invocations <> 1 then + failwith "Callback evaluation or suspension was lost" + ready.SetResult 42 + if result.GetAwaiter().GetResult() <> 42 || constructions <> 1 || invocations <> 1 then + failwith "Recursive callback returned an incorrect result" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + [] let ``runtime async ignores unreachable suspension`` () = FSharp """ From c1c326c6341c258fdb3641f3e083f409f445a620 Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:16:29 +0200 Subject: [PATCH 18/30] Link runtime-async let-rec fix to issue and callback PR --- docs/release-notes/.FSharp.Compiler.Service/11.0.200.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 4ec79978330..4cc19f09b4f 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -8,7 +8,7 @@ * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) * Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Inlined callback statements keep their own debug points, and captured mutable locals remain visible in the debugger during callback construction. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) -* Fix spurious FS3918 errors when a runtime-async callback defines recursive local functions before returning a suspending closure. +* Fix spurious FS3918 errors when a runtime-async callback defines recursive local functions before returning a suspending closure. ([Issue #20737](https://github.com/dotnet/fsharp/issues/20737), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * Preserve tupled lambda argument groups when reducing state-machine applications, avoiding invalid argument substitutions and runtime failures with inline formatters. ([Issue #20675](https://github.com/dotnet/fsharp/issues/20675), [PR #20700](https://github.com/dotnet/fsharp/pull/20700)) From f9cf02f532b3055b99a47a8ed29c5d0e491dc974 Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:07:23 +0200 Subject: [PATCH 19/30] Preserve lexical debug scopes for runtime-async captures --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- src/Compiler/CodeGen/IlxGen.fs | 26 ++++++ src/Compiler/Optimize/LowerLocalMutables.fs | 5 +- src/Compiler/Optimize/LowerRuntimeAsync.fs | 1 + src/Compiler/Optimize/Optimizer.fs | 2 + src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 46 ++++++++-- src/Compiler/TypedTree/TypedTree.fs | 4 + src/Compiler/TypedTree/TypedTree.fsi | 3 + .../TypedTree/TypedTreeOps.Remapping.fs | 6 +- src/Compiler/TypedTree/TypedTreePickle.fs | 3 +- .../Language/RuntimeAsyncTests.fs | 92 +++++++++++++++---- 11 files changed, 163 insertions(+), 27 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 53e51cb9ff3..a8ee7d9e3a4 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Inlined callback statements keep their own debug points, and captured mutable locals remain visible in the debugger during callback construction. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Inlined callback statements keep their own debug points, and captured mutable locals retain their lexical debugger scopes during construction and invocation without adding reference-cell allocations. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Prevent `InvalidProgramException` when runtime async awaits a `Task` upcast to `Task`, including through `ConfigureAwait`. ([Issue #20686](https://github.com/dotnet/fsharp/issues/20686), [PR #20726](https://github.com/dotnet/fsharp/pull/20726)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * Build parser diagnostic messages without runtime printf specialization or reflective function construction, so Native AOT hosts report indentation and invalid-character diagnostics instead of failing during formatting. Preserve `#r` reference tooltips while script project options remain alive, including after garbage collection or cache eviction. ([PR #20658](https://github.com/dotnet/fsharp/pull/20658)) diff --git a/src/Compiler/CodeGen/IlxGen.fs b/src/Compiler/CodeGen/IlxGen.fs index abe538bf989..d4f6faea4b7 100644 --- a/src/Compiler/CodeGen/IlxGen.fs +++ b/src/Compiler/CodeGen/IlxGen.fs @@ -2952,6 +2952,10 @@ type CodeGenBuffer(m: range, mgbuf: AssemblyBuilder, methodName, alreadyUsedArgs locals.Add((ranges, ty, isFixed, canBeReallocd)) j + member _.AddLocalDebugRange(index, name, scopeMarks) = + let ranges, ty, isFixed, canBeReallocd = locals[index] + locals[index] <- ((name, scopeMarks) :: ranges, ty, isFixed, canBeReallocd) + member cgbuf.ReallocLocal(cond, ranges, ty, isFixed, canBeReallocd) = match ResizeArray.tryFindIndexi cond locals with | Some j -> @@ -3349,6 +3353,28 @@ and GenExprAux (cenv: cenv) (cgbuf: CodeGenBuffer) eenv expr (sequel: sequel) = | Expr.LetRec(binds, body, m, _) -> GenLetRec cenv cgbuf eenv (binds, body, m) sequel + | Expr.Op(TOp.DebugLocalScope(vref, name), [], [ body ], m) -> + if cenv.options.generateDebugSymbols then + let _, endMark as scopeMarks = StartLocalScope "debugLocal" cgbuf + + match StorageForValRef m vref eenv with + | Local(index, _, _) -> cgbuf.AddLocalDebugRange(index, name, scopeMarks) + | _ when not cenv.options.localOptimizationsEnabled -> + if vref.IsMutable && not (Optimizer.IsKnownOnlyMutableBeforeUse vref) then + error (InternalError("debug local scope requires shared storage for a mutable capture", m)) + + // Escaping mutables are reference cells, so the alias copies their shared home. + let index = + cgbuf.AllocLocal([ (name, scopeMarks) ], GenTypeOfVal cenv eenv vref.Deref, false, false) + + GenGetVal cenv cgbuf eenv (vref, m) Continue + EmitSetLocal cgbuf index + | _ -> () + + GenExpr cenv cgbuf eenv body (EndLocalScope(sequel, endMark)) + else + GenExpr cenv cgbuf eenv body sequel + | Expr.Lambda _ | Expr.TyLambda _ -> GenLambda cenv cgbuf eenv false [] expr sequel diff --git a/src/Compiler/Optimize/LowerLocalMutables.fs b/src/Compiler/Optimize/LowerLocalMutables.fs index 5ab29e065b1..12c7883be96 100644 --- a/src/Compiler/Optimize/LowerLocalMutables.fs +++ b/src/Compiler/Optimize/LowerLocalMutables.fs @@ -145,6 +145,10 @@ let DecideImplFile g amap implFile = let TransformExpr g (heapValMap: ValMap<_>) exprF expr = match expr with + | Expr.Op (TOp.DebugLocalScope (ValDeref(v), name), [], [body], m) when heapValMap.ContainsVal v -> + let nv, _ = heapValMap[v] + Some (Expr.Op (TOp.DebugLocalScope (mkLocalValRef nv, name), [], [exprF body], m)) + // Rewrite uses of mutable values | Expr.Val (ValDeref(v), _, m) when heapValMap.ContainsVal v -> @@ -202,4 +206,3 @@ let TransformImplFile g amap implFile = PostTransform = (fun _ -> None) RewriteQuotations = true StackGuard = StackGuard("AutoboxRewriteStackGuardDepth") } - diff --git a/src/Compiler/Optimize/LowerRuntimeAsync.fs b/src/Compiler/Optimize/LowerRuntimeAsync.fs index 6d27712b853..08078891cef 100644 --- a/src/Compiler/Optimize/LowerRuntimeAsync.fs +++ b/src/Compiler/Optimize/LowerRuntimeAsync.fs @@ -44,6 +44,7 @@ let private inlineCallbacks g implFile = match expr with | Expr.Let(_, rest, _, _) | Expr.DebugPoint(_, rest) + | Expr.Op(TOp.DebugLocalScope _, [], [ rest ], _) | Expr.Sequential(_, rest, NormalSeq, _) -> leadsToRuntimeAsyncReturn rest | _ -> (TryGetRuntimeAsyncReturn g expr).IsSome diff --git a/src/Compiler/Optimize/Optimizer.fs b/src/Compiler/Optimize/Optimizer.fs index 9f7f4695623..b23769953dc 100644 --- a/src/Compiler/Optimize/Optimizer.fs +++ b/src/Compiler/Optimize/Optimizer.fs @@ -1739,6 +1739,7 @@ and BindingHasEffect context g bind = bind.Expr |> ExprHasEffect context g and OpHasEffect context g m op tyargs = match op with + | TOp.DebugLocalScope _ -> false | TOp.Tuple _ -> false | TOp.AnonRecd _ -> false | TOp.Recd (ctor, tcref) -> @@ -3098,6 +3099,7 @@ and OptimizeExprOpFallback cenv env (op, tyargs, argsR, m) arginfos value_ = let effect = OpHasEffect (effectContextOf cenv) g m op tyargs let cost, value_ = match op with + | TOp.DebugLocalScope _ -> 0, argValues[0] | TOp.UnionCase c -> 2, MakeValueInfoForUnionCase c (Array.ofList argValues) | TOp.ExnConstr _ -> 2, value_ diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 27f1b446bd6..e0f0633eff6 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -180,6 +180,9 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt -> apply inner fty tyargs args m | Expr.DebugPoint(point, inner), _ -> apply inner fty tyargs args m |> Option.map (fun r -> Expr.DebugPoint(point, r)) + | Expr.Op(TOp.DebugLocalScope _ as op, [], [ body ], mScope), _ -> + apply body (tyOfExpr g body) tyargs args m + |> Option.map (fun body -> Expr.Op(op, [], [ body ], mScope)) | Expr.Let(binding, body, mLet, _), _ -> apply body (tyOfExpr g body) tyargs args m |> Option.map (mkLetBind mLet binding) @@ -214,10 +217,14 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt PostTransform = (fun expression -> match expression with - | Expr.App((Expr.Lambda _ | Expr.Let _ | Expr.LetRec _ | Expr.Match _ | Expr.DebugPoint _) as f, fty, tyargs, args, m) when - not args.IsEmpty && analyzer.ContainsSuspension expression - -> - apply f fty tyargs args m + | Expr.App((Expr.Lambda _ | Expr.Let _ | Expr.LetRec _ | Expr.Match _ | Expr.DebugPoint _ | Expr.Op(TOp.DebugLocalScope _, + _, + _, + _)) as f, + fty, + tyargs, + args, + m) when not args.IsEmpty && analyzer.ContainsSuspension expression -> apply f fty tyargs args m | _ -> None) RewriteQuotations = false StackGuard = StackGuard("ReduceRuntimeAsyncReturnedClosureApplications") @@ -280,7 +287,15 @@ let private tryShareMutableCallbackCaptures (g: TcGlobals) (mutableCaptures: (Va | DebugPointAtBinding.Yes point -> mkDebugPoint point assignment | _ -> assignment - Some(mkCompGenSequential m assignment (rewrite body)) + let body = rewrite body + + let body = + if v.IsCompilerGenerated then + body + else + Expr.Op(TOp.DebugLocalScope(hoistedOf[v], v.LogicalName), [], [ body ], m) + + Some(mkCompGenSequential m assignment body) | _ -> None) PreInterceptBinding = None PostTransform = (fun _ -> None) @@ -327,7 +342,9 @@ let private tryDefunctionalizeCallback (g: TcGlobals) m construction = | _ -> let hoisted = if v.IsMutable then - Construct.NewModifiedVal id v + let hoisted = Construct.NewModifiedVal id v + hoisted.SetIsCompilerGenerated true + hoisted else fst (mkMutableCompGenLocal m v.LogicalName v.Type) @@ -345,6 +362,8 @@ let private tryDefunctionalizeCallback (g: TcGlobals) m construction = branches.Add(parameter, body) Some(List.foldBack (mkCompGenSequential m) assignments select) | Expr.DebugPoint(point, body) -> defunctionalize body |> Option.map (fun body -> Expr.DebugPoint(point, body)) + | Expr.Op(TOp.DebugLocalScope _ as op, [], [ body ], m) -> + defunctionalize body |> Option.map (fun body -> Expr.Op(op, [], [ body ], m)) | Expr.Sequential(first, rest, NormalSeq, m) -> defunctionalize rest |> Option.map (fun rest -> Expr.Sequential(first, rest, NormalSeq, m)) @@ -392,7 +411,15 @@ let private mkCallbackDispatch (g: TcGlobals) (callback: CallbackBranches) arg r valRemap = ValMap.OfList((parameter, mkLocalValRef argVal) :: captureRemap) } - remapExpr g CloneAll remap body + let body = remapExpr g CloneAll remap body + let captured = (freeInExpr CollectLocals body).FreeLocals + + (callback.Captures, body) + ||> List.foldBack (fun (v, hoisted) body -> + if v.IsMutable && not v.IsCompilerGenerated && Zset.contains hoisted captured then + Expr.Op(TOp.DebugLocalScope(mkLocalValRef hoisted, v.LogicalName), [], [ body ], m) + else + body) let rec dispatch i branches = match branches with @@ -410,6 +437,7 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r match expr with | NewDelegateExpr g (_, [ parameter ], body, _, _) -> Some(parameter.Type, tyOfExpr g body) | Expr.DebugPoint(_, rest) + | Expr.Op(TOp.DebugLocalScope _, [], [ rest ], _) | Expr.Sequential(_, rest, NormalSeq, _) | Expr.Let(_, rest, _, _) -> tryDelegateSignature rest | Expr.Match(_, _, _, targets, _, _) when targets.Length > 0 -> @@ -431,6 +459,9 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r | Expr.DebugPoint(point, rest) -> inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest |> Option.map (fun body -> Expr.DebugPoint(point, body)) + | Expr.Op(TOp.DebugLocalScope _ as op, [], [ rest ], m) -> + inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest + |> Option.map (fun body -> Expr.Op(op, [], [ body ], m)) | Expr.Sequential(first, rest, NormalSeq, m) when isTrivialValue first -> inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest |> Option.map (fun body -> Expr.Sequential(first, body, NormalSeq, m)) @@ -599,6 +630,7 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r | _ -> keep construction | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (inlineCallbacks continuation) | Expr.DebugPoint(point, inner) -> Expr.DebugPoint(point, inlineCallbacks inner) + | Expr.Op(TOp.DebugLocalScope _ as op, [], [ body ], m) -> Expr.Op(op, [], [ inlineCallbacks body ], m) | Expr.Sequential(first, rest, NormalSeq, m) -> Expr.Sequential(first, inlineCallbacks rest, NormalSeq, m) | _ -> expr diff --git a/src/Compiler/TypedTree/TypedTree.fs b/src/Compiler/TypedTree/TypedTree.fs index 4f62c6f9ace..45bdbe5fe64 100644 --- a/src/Compiler/TypedTree/TypedTree.fs +++ b/src/Compiler/TypedTree/TypedTree.fs @@ -5555,6 +5555,9 @@ type TOp = /// Operation nodes representing C-style operations on byrefs and mutable vals (l-values) | LValueOp of LValueOperation * ValRef + /// Exposes existing storage under a source name for the scope of the single body argument. + | DebugLocalScope of ValRef * name: string + /// IL method calls. /// isProperty -- used for quotation reflection, property getters & setters /// noTailCall - DllImport? if so don't tailcall @@ -5610,6 +5613,7 @@ type TOp = | Label n -> "Label(" + string n + ")" | TraitCall info -> "TraitCall(" + info.MemberLogicalName + ")" | LValueOp (op, vref) -> sprintf "%+A(%s)" op vref.LogicalName + | DebugLocalScope (_, name) -> "DebugLocalScope(" + name + ")" | ILCall (_,_,_,_,_,_,_,ilMethRef,_,_,_) -> "ILCall(" + ilMethRef.ToString() + ",..)" /// Represents the kind of record construction operation. diff --git a/src/Compiler/TypedTree/TypedTree.fsi b/src/Compiler/TypedTree/TypedTree.fsi index d93c232341f..2a804a3aeb5 100644 --- a/src/Compiler/TypedTree/TypedTree.fsi +++ b/src/Compiler/TypedTree/TypedTree.fsi @@ -3921,6 +3921,9 @@ type TOp = /// Operation nodes representing C-style operations on byrefs type mutable vals (l-values) | LValueOp of LValueOperation * ValRef + /// Exposes existing storage under a source name for the scope of the single body argument. + | DebugLocalScope of ValRef * name: string + /// IL method calls. /// isProperty -- used for quotation reflection, property getters & setters /// noTailCall - DllImport? if so don't tailcall diff --git a/src/Compiler/TypedTree/TypedTreeOps.Remapping.fs b/src/Compiler/TypedTree/TypedTreeOps.Remapping.fs index df53a1675b4..98e31cca75e 100644 --- a/src/Compiler/TypedTree/TypedTreeOps.Remapping.fs +++ b/src/Compiler/TypedTree/TypedTreeOps.Remapping.fs @@ -1272,7 +1272,8 @@ module internal ExprFreeVars = sln.Value (accFreeVarsInTys opts tys (accFreeVarsInTys opts argTys (Option.foldBack (accFreeVarsInTy opts) retTy acc))) - | TOp.LValueOp(_, vref) -> accFreeValRef opts vref acc + | TOp.LValueOp(_, vref) + | TOp.DebugLocalScope(vref, _) -> accFreeValRef opts vref acc | TOp.ILCall(_, isProtected, _, _, valUseFlag, _, _, _, enclTypeInst, methInst, retTypes) -> accFreeVarsInTys @@ -2061,6 +2062,7 @@ module internal ExprRemapping = TOp.ILAsm(instrs, retTypes2) | TOp.TraitCall traitInfo -> TOp.TraitCall(remapTraitInfo tmenv traitInfo) | TOp.LValueOp(kind, lvr) -> TOp.LValueOp(kind, remapValRef tmenv lvr) + | TOp.DebugLocalScope(vref, name) -> TOp.DebugLocalScope(remapValRef tmenv vref, name) | TOp.ILCall(isVirtual, isProtected, isStruct, @@ -2771,6 +2773,7 @@ module internal ExprAnalysis = | Expr.DebugPoint(_, e) | Expr.StaticOptimization(_, _, e, _) | Expr.LetRec(_, e, _, _) -> tyOfExpr g e + | Expr.Op(TOp.DebugLocalScope _, _, [ body ], _) -> tyOfExpr g body | Expr.Op(op, tinst, _, _) -> match op with | TOp.Coerce -> @@ -2825,6 +2828,7 @@ module internal ExprAnalysis = (match tinst with | [ rtn_ty ] -> rtn_ty | _ -> failwith "bad TOp.Reraise node") + | TOp.DebugLocalScope _ -> failwith "bad TOp.DebugLocalScope node" | TOp.Goto _ | TOp.Label _ | TOp.Return -> diff --git a/src/Compiler/TypedTree/TypedTreePickle.fs b/src/Compiler/TypedTree/TypedTreePickle.fs index dbe6964b62c..24d4143766b 100644 --- a/src/Compiler/TypedTree/TypedTreePickle.fs +++ b/src/Compiler/TypedTree/TypedTreePickle.fs @@ -3666,7 +3666,8 @@ and p_op x st = p_int n st | TOp.Goto _ | TOp.Label _ - | TOp.Return -> failwith "unexpected backend construct in pickled TAST" + | TOp.Return + | TOp.DebugLocalScope _ -> failwith "unexpected backend construct in pickled TAST" and u_op st = let tag = u_byte st diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 70ac0ddfc33..b5d6368d8c7 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1602,10 +1602,11 @@ let work (ready: Task) = |> shouldSucceed |> verifyPdb [ VerifyMethodSequencePoints("work", (if optimize then statementPoints else callSitePoint @ statementPoints)) ] -[] -let ``runtime async conditional callback keeps mutable capture visible during construction`` () = - let result = - FSharp """module MissingMutableLocal +[] +[] +[] +let ``runtime async conditional callback keeps mutable capture in its lexical scope`` (insideContext: bool) = + let source = """module MissingMutableLocal open System.Threading.Tasks open System.Runtime.CompilerServices @@ -1615,8 +1616,10 @@ let inline invoke ([] callback: unit -> int) : Task = StateMachineHelpers.__runtimeAsyncReturn (callback ()) let run flag (ready: Task) = + let mutable count = 99 invoke ( if flag then + printfn "outer = %d" count let mutable count = 0 printfn "%d" count fun () -> @@ -1624,8 +1627,20 @@ let run flag (ready: Task) = count <- count + value count else + printfn "else = %d" count fun () -> 0) """ + let source = + if insideContext then + source + .Replace(" : Task =", " =") + .Replace("StateMachineHelpers.__runtimeAsyncReturn (callback ())", "callback ()") + .Replace("let run flag (ready: Task) =", "let run flag (ready: Task) = StateMachineHelpers.__runtimeAsyncReturn (") + .Replace("fun () -> 0)", "fun () -> 0))") + else + source + let result = + FSharp source |> withLangVersionPreview |> withFSharpCoreShippedNet |> withPortablePdb @@ -1642,20 +1657,55 @@ let run flag (ready: Task) = md.MethodDefinitions |> Seq.filter (fun handle -> md.GetString(md.GetMethodDefinition(handle).Name) = "run") |> Assert.Single - let point = - pdb.GetMethodDebugInformation(method).GetSequencePoints() - |> Seq.filter (fun point -> not point.IsHidden && point.StartLine = 14) - |> Assert.Single - let local = + let localsAt (method: MethodDefinitionHandle) line = + let point = + pdb.GetMethodDebugInformation(method).GetSequencePoints() + |> Seq.filter (fun point -> not point.IsHidden && point.StartLine = line) + |> Assert.Single [ for handle in pdb.GetLocalScopes method do let scope = pdb.GetLocalScope handle if scope.StartOffset <= point.Offset && point.Offset < scope.EndOffset then for local in scope.GetLocalVariables() do let variable = pdb.GetLocalVariable local - if pdb.GetString variable.Name = "count" then - yield variable ] + yield pdb.GetString variable.Name, variable ] + let outerLocals = localsAt method 14 + Assert.DoesNotContain("count (shadowed)", outerLocals |> List.map fst) + let outer = + outerLocals + |> List.filter (fun (name, _) -> name = "count") + |> Assert.Single + |> snd + let inner = + localsAt method 16 + |> List.filter (fun (name, _) -> name = "count") + |> Assert.Single + |> snd + Assert.NotEqual(outer.Index, inner.Index) + Assert.Equal(LocalVariableAttributes.None, inner.Attributes) + let elseLocals = localsAt method 22 + Assert.DoesNotContain("count (shadowed)", elseLocals |> List.map fst) + let restoredOuter = + elseLocals + |> List.filter (fun (name, _) -> name = "count") + |> Assert.Single + |> snd + Assert.Equal(outer.Index, restoredOuter.Index) + let invocationMethod = + md.MethodDefinitions + |> Seq.filter (fun handle -> + pdb.GetMethodDebugInformation(handle).GetSequencePoints() + |> Seq.exists (fun point -> not point.IsHidden && point.StartLine = 19)) + |> Assert.Single + let invoked = + localsAt invocationMethod 19 + |> List.filter (fun (name, _) -> name = "count") |> Assert.Single - Assert.Equal(LocalVariableAttributes.None, local.Attributes)) + |> snd + Assert.Equal(LocalVariableAttributes.None, invoked.Attributes) + if insideContext then + Assert.Equal(inner.Index, invoked.Index)) + if insideContext then + result |> verifyILNotPresent [ "FSharpRef" ] [] let ``runtime async suspension in exception region executes`` () = @@ -2314,7 +2364,7 @@ let main _ = [] [] [] -let ``runtime async inlines nested branch-selected callbacks`` (optimize: bool) = +let ``runtime async inlines nested stateful branch-selected callbacks`` (optimize: bool) = FSharp """ module RuntimeAsyncNestedBranchCallbacks open System.Threading.Tasks @@ -2330,14 +2380,24 @@ let run (gate: Task) f1 f2 = StateMachineHelpers.__runtimeAsyncReturn ( pick f1 - (if f2 then (fun x -> AsyncHelpers.Await gate + x) else (fun x -> x * 2)) - (if f2 then (fun x -> x + 1) else (fun x -> AsyncHelpers.Await gate - x))) + (if f2 then + let mutable count = 0 + fun x -> + count <- count + 1 + AsyncHelpers.Await gate + x + count + else fun x -> x * 2) + (if f2 then fun x -> x + 1 + else + let mutable count = 0 + fun x -> + count <- count + 1 + AsyncHelpers.Await gate - x + count)) [] let main _ = let r a b = (run (Task.FromResult 10) a b).Result let results = r true true, r true false, r false true, r false false - if results <> (45, 36, 39, 18) then failwithf "Unexpected: %A" results + if results <> (51, 42, 45, 24) then failwithf "Unexpected: %A" results 0 """ |> withLangVersionPreview From 2967313dacaa63768668f697c030e55b040f6317 Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:37:32 +0200 Subject: [PATCH 20/30] Bound runtime-async callback copying before expansion --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- docs/runtime-async.md | 9 ++- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 78 ++++++++++++------- .../Language/RuntimeAsyncTests.fs | 46 ++++++++--- 4 files changed, 95 insertions(+), 40 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index a8ee7d9e3a4..46745e7ed99 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Inlined callback statements keep their own debug points, and captured mutable locals retain their lexical debugger scopes during construction and invocation without adding reference-cell allocations. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Inlined callback statements keep their own debug points, and captured mutable locals retain their lexical debugger scopes during construction and invocation without adding reference-cell allocations. Branch sizes and captures are cached, and the existing copy budget is enforced before constructing copies. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Prevent `InvalidProgramException` when runtime async awaits a `Task` upcast to `Task`, including through `ConfigureAwait`. ([Issue #20686](https://github.com/dotnet/fsharp/issues/20686), [PR #20726](https://github.com/dotnet/fsharp/pull/20726)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * Build parser diagnostic messages without runtime printf specialization or reflective function construction, so Native AOT hosts report indentation and invalid-character diagnostics instead of failing during formatting. Preserve `#r` reference tooltips while script project options remain alive, including after garbage collection or cache eviction. ([PR #20658](https://github.com/dotnet/fsharp/pull/20658)) diff --git a/docs/runtime-async.md b/docs/runtime-async.md index 794df6f4292..e98d80636f3 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -200,9 +200,12 @@ when the enclosing method is runtime-async. Exported inline definitions remain unchanged. Each invocation copies every branch body, so code size grows with the number -of invocations times the number of branches. When the copies would exceed -2000 expression nodes, the callback is left as a closure and a suspension in -it is reported as FS3918. +of invocations times the number of branches. Branch sizes and captures are +analyzed once. Each invocation reserves its share of the 2000-expression-node +copy budget before rewriting its argument or copying its branches; invocations +nested in arguments use the same budget. When the copies would exceed the +budget, the callback is left as a closure and a suspension in it is reported +as FS3918. Second, every return-marker body is prepared once after callbacks are inlined, innermost first: locals that cannot be preserved across a suspension are diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index e0f0633eff6..7b96760cee4 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -251,13 +251,21 @@ let PreserveRuntimeAsyncCallSiteDebugPoint (g: TcGlobals) m expr = RewriteExpr rwenv expr +type private CallbackBranch = + { + Parameter: Val + Body: Expr + Captures: Val list + NodeCount: int + } + /// A callback construction whose branches select one of several lambdas. The construction runs once /// and records the selected branch and the values it captures; each invocation dispatches on the branch. type private CallbackBranches = { Construction: Expr Tag: Val - Branches: (Val * Expr) list + Branches: CallbackBranch list Captures: (Val * Val) list } @@ -310,12 +318,20 @@ let private tryShareMutableCallbackCaptures (g: TcGlobals) (mutableCaptures: (Va else None +let private exprNodeCount expr = + let folder = + { ExprFolder0 with + exprIntercept = fun _ noInterceptF count expr -> noInterceptF (count + 1) expr + } + + FoldExpr folder 0 expr + /// Replaces each lambda in tail position of a callback construction with an assignment of its branch tag /// and captured locals, or returns None if a tail is not a supported single-argument lambda. let private tryDefunctionalizeCallback (g: TcGlobals) m construction = let constructionFree = (freeInExpr CollectLocals construction).FreeLocals let tag, _ = mkMutableCompGenLocal m "runtimeAsyncCallbackTag" g.int32_ty - let branches = ResizeArray() + let branches = ResizeArray() let captures = Dictionary() let rec defunctionalize expr = @@ -359,7 +375,15 @@ let private tryDefunctionalizeCallback (g: TcGlobals) m construction = |> List.choose id let select = mkValSet m (mkLocalValRef tag) (mkInt32 g m branches.Count) - branches.Add(parameter, body) + + branches.Add + { + Parameter = parameter + Body = body + Captures = captured + NodeCount = exprNodeCount body + } + Some(List.foldBack (mkCompGenSequential m) assignments select) | Expr.DebugPoint(point, body) -> defunctionalize body |> Option.map (fun body -> Expr.DebugPoint(point, body)) | Expr.Op(TOp.DebugLocalScope _ as op, [], [ body ], m) -> @@ -389,35 +413,28 @@ let private tryDefunctionalizeCallback (g: TcGlobals) m construction = /// expression nodes; the callback then stays a closure and a suspension left in it is reported as FS3918. let private maxInlinedCallbackCopySize = 2000 -let private exprNodeCount expr = - let folder = - { ExprFolder0 with - exprIntercept = fun _ noInterceptF count expr -> noInterceptF (count + 1) expr - } - - FoldExpr folder 0 expr - /// Inlines an invocation of a defunctionalized callback: the argument is bound once, then each branch body /// is copied with its parameter and captured locals remapped. let private mkCallbackDispatch (g: TcGlobals) (callback: CallbackBranches) arg resultTy m = let argVal, _ = mkCompGenLocal m "runtimeAsyncCallbackArg" (tyOfExpr g arg) let captureRemap = - callback.Captures |> List.map (fun (v, hoisted) -> v, mkLocalValRef hoisted) + callback.Captures + |> List.map (fun (v, hoisted) -> v, mkLocalValRef hoisted) + |> ValMap.OfList - let branch (parameter: Val, body) = + let branch branch = let remap = { emptyRemap with - valRemap = ValMap.OfList((parameter, mkLocalValRef argVal) :: captureRemap) + valRemap = captureRemap.Add branch.Parameter (mkLocalValRef argVal) } - let body = remapExpr g CloneAll remap body - let captured = (freeInExpr CollectLocals body).FreeLocals + let body = remapExpr g CloneAll remap branch.Body - (callback.Captures, body) - ||> List.foldBack (fun (v, hoisted) body -> - if v.IsMutable && not v.IsCompilerGenerated && Zset.contains hoisted captured then - Expr.Op(TOp.DebugLocalScope(mkLocalValRef hoisted, v.LogicalName), [], [ body ], m) + (branch.Captures, body) + ||> List.foldBack (fun v body -> + if v.IsMutable && not v.IsCompilerGenerated then + Expr.Op(TOp.DebugLocalScope(captureRemap[v], v.LogicalName), [], [ body ], m) else body) @@ -572,7 +589,8 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r | Some body, _ -> inlineCallbacks body | None, Some callbackBranches -> let mutable invalidUse = false - let mutable invocations = 0 + let copySize = List.sumBy _.NodeCount callbackBranches.Branches + let mutable remainingCopySize = maxInlinedCallbackCopySize let rwenv = { @@ -591,9 +609,18 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r | _ -> None match call with + | _ when invalidUse -> Some expression + | Some _ when copySize > remainingCopySize -> + invalidUse <- true + Some expression | Some(arg, callRange) -> - invocations <- invocations + 1 - Some(mkCallbackDispatch g callbackBranches (rewrite arg) (tyOfExpr g expression) callRange) + remainingCopySize <- remainingCopySize - copySize + let arg = rewrite arg + + if invalidUse then + Some expression + else + Some(mkCallbackDispatch g callbackBranches arg (tyOfExpr g expression) callRange) | None -> match expression with | Expr.Val(vref, _, _) when valEq callback vref.Deref -> @@ -611,10 +638,7 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r let continuation = RewriteExpr rwenv continuation - let copySize = - invocations * List.sumBy (snd >> exprNodeCount) callbackBranches.Branches - - if invalidUse || copySize > maxInlinedCallbackCopySize then + if invalidUse then keep construction else let construction = diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index b5d6368d8c7..b197d1ac8ee 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -2406,12 +2406,23 @@ let main _ = |> compileExeAndRun |> shouldSucceed -[] -let ``runtime async rejects a branch-selected callback too large to copy into every invocation`` () = - let calls = List.init 40 (sprintf "f %d") |> String.concat " + " +[] +[] +[] +[] +[] +[] +let ``runtime async bounds branch-selected callback copies across all invocations`` (invocations: int, nested: bool, withinBudget: bool) = + let calls = + if nested then + [1 .. invocations] |> List.fold (fun arg _ -> $"f ({arg})") "1" + else + List.init invocations (sprintf "f %d") |> String.concat " + " + let terms = List.init 30 (sprintf "x * %d") |> String.concat " + " - FSharp $""" + let compilation = + FSharp $""" module RuntimeAsyncOversizedCallback open System.Threading.Tasks open System.Runtime.CompilerServices @@ -2422,12 +2433,29 @@ let inline invoke ([] f: int -> int) = {calls} let run (gate: Task) flag = StateMachineHelpers.__runtimeAsyncReturn ( invoke (if flag then (fun x -> AsyncHelpers.Await gate + {terms}) else (fun x -> x))) + +[] +let main _ = + let evaluate x = List.fold (fun total n -> total + x * n) 10 [0 .. 29] + let expected = + if {nested.ToString().ToLowerInvariant()} then + [1 .. {invocations}] |> List.fold (fun arg _ -> evaluate arg) 1 + else + [0 .. {invocations - 1}] |> List.sumBy evaluate + let expectedOtherBranch = if {nested.ToString().ToLowerInvariant()} then 1 else {invocations * (invocations - 1) / 2} + let result flag = (run (Task.FromResult 10) flag).Result + if result true <> expected then failwith "Unexpected suspending branch result" + if result false <> expectedOtherBranch then failwith "Unexpected other branch result" + 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compile - |> shouldFail - |> withErrorCode 3918 + |> asExe + |> withLangVersionPreview + |> withFSharpCoreShippedNet + + if withinBudget then + compilation |> compileExeAndRun |> shouldSucceed + else + compilation |> compile |> shouldFail |> withErrorCode 3918 [] [] From 375ce9b50ac85deaaad199ef473adc2e0cf04488 Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:55:21 +0200 Subject: [PATCH 21/30] Analyze runtime-async callback uses before rewriting --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- docs/runtime-async.md | 12 +- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 158 +++++++++--------- .../Language/RuntimeAsyncTests.fs | 55 ++++-- 4 files changed, 128 insertions(+), 99 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 46745e7ed99..c2a8aacf2a2 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Inlined callback statements keep their own debug points, and captured mutable locals retain their lexical debugger scopes during construction and invocation without adding reference-cell allocations. Branch sizes and captures are cached, and the existing copy budget is enforced before constructing copies. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Inlined callback statements keep their own debug points, and captured mutable locals retain their lexical debugger scopes during construction and invocation without adding reference-cell allocations. Branch sizes and captures are cached, and callback uses, single-use delegate eligibility, and the existing copy budget are checked before expanding invocations. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Prevent `InvalidProgramException` when runtime async awaits a `Task` upcast to `Task`, including through `ConfigureAwait`. ([Issue #20686](https://github.com/dotnet/fsharp/issues/20686), [PR #20726](https://github.com/dotnet/fsharp/pull/20726)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * Build parser diagnostic messages without runtime printf specialization or reflective function construction, so Native AOT hosts report indentation and invalid-character diagnostics instead of failing during formatting. Preserve `#r` reference tooltips while script project options remain alive, including after garbage collection or cache eviction. ([PR #20658](https://github.com/dotnet/fsharp/pull/20658)) diff --git a/docs/runtime-async.md b/docs/runtime-async.md index e98d80636f3..41ee8e79ccd 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -201,11 +201,13 @@ unchanged. Each invocation copies every branch body, so code size grows with the number of invocations times the number of branches. Branch sizes and captures are -analyzed once. Each invocation reserves its share of the 2000-expression-node -copy budget before rewriting its argument or copying its branches; invocations -nested in arguments use the same budget. When the copies would exceed the -budget, the callback is left as a closure and a suspension in it is reported -as FS3918. +analyzed once. Callback uses are analyzed separately before rewriting: +opaque consumers and quotations retaining the callback reject the candidate, +and invocations nested in arguments count towards the same +2000-expression-node copy budget. The total cost is checked before copying any +branch bodies. A rejected callback is left as a closure and a suspension in +it is reported as FS3918. Single-use delegate inlining also checks use count +and effect-free construction before rewriting its invocation. Second, every return-marker body is prepared once after callbacks are inlined, innermost first: locals that cannot be preserved across a suspension are diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 7b96760cee4..6d2d5efcf6d 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -161,6 +161,39 @@ let private isQuotationUsing (v: Val) expr = body | _ -> false +let private tryCallbackInvocation (g: TcGlobals) (callback: Val) isDelegate expression = + match expression with + | Expr.App(Expr.Val(vref, _, _), _, [], [ arg ], m) when not isDelegate && valEq callback vref.Deref -> ValueSome(struct (arg, m)) + | DelegateInvokeExpr g (_, _, _, Expr.Val(vref, _, _), arg, m) when isDelegate && valEq callback vref.Deref -> + ValueSome(struct (arg, m)) + | _ -> ValueNone + +let private tryAnalyzeCallbackUses g callback isDelegate continuation = + let folder = + { ExprFolder0 with + exprIntercept = + fun recurse noIntercept uses expression -> + match uses with + | ValueNone -> ValueNone + | ValueSome count -> + match tryCallbackInvocation g callback isDelegate expression with + | ValueSome(struct (arg, _)) -> recurse (ValueSome(count + 1)) arg + | ValueNone -> + match expression with + | Expr.Val(vref, _, _) when valEq callback vref.Deref -> ValueNone + | Expr.Quote(_, dataCell, _, _, _) -> + if isQuotationUsing callback expression then + ValueNone + else + match dataCell.Value with + | Some((_, _, args, _), (_, _, legacyArgs, _)) -> + List.fold recurse (List.fold recurse uses args) legacyArgs + | None -> uses + | _ -> noIntercept uses expression + } + + FoldExpr folder (ValueSome 0) continuation + let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) expr = let rec effectFree expr = match stripExpr expr with @@ -468,6 +501,20 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r | Expr.Val(vref, _, _) -> vref.IsLocalRef && not vref.IsMutable && not vref.IsTypeFunction | _ -> false + let rec canInlineDelegateConstruction construction = + match construction with + | NewDelegateExpr g (_, [ _ ], _, _, _) -> true + | Expr.DebugPoint(_, rest) + | Expr.Op(TOp.DebugLocalScope _, [], [ rest ], _) -> canInlineDelegateConstruction rest + | Expr.Sequential(first, rest, NormalSeq, _) when isTrivialValue first -> canInlineDelegateConstruction rest + | Expr.Let(TBind(_, rhs, _), rest, _, _) when isTrivialValue rhs -> canInlineDelegateConstruction rest + | Expr.Match(_, _, TDSwitch(Expr.Val(vref, _, _), [ TCase(_, TDSuccess([], _)) ], Some(TDSuccess([], _)), _), targets, _, _) when + vref.IsLocalRef && not vref.IsMutable && not vref.IsTypeFunction + -> + targets.Length > 0 + && Array.forall (fun (TTarget(_, body, _)) -> canInlineDelegateConstruction body) targets + | _ -> false + // Construction moves to the invocation, so its evaluation must not have observable effects. let rec inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) construction = match construction with @@ -496,10 +543,7 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r | _ -> None // A residual Invoke can surface after ordinary optimization; try its single use before dispatching on branches. - let tryInlineDelegate callback construction continuation = - let mutable invocations = 0 - let mutable invalidUse = false - + let inlineDelegate callback construction continuation = let rwenv = { PreIntercept = @@ -508,19 +552,9 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r | DelegateInvokeExpr g (invokeRef, invokeTy, tyargs, Expr.Val(vref, _, _), arg, callRange) when valEq callback vref.Deref -> - match inlineDelegateInvoke (invokeRef, invokeTy, tyargs, rewrite arg, callRange) construction with - | Some body -> - invocations <- invocations + 1 - Some body - | None -> - invalidUse <- true - Some expression - | Expr.Val(vref, _, _) when valEq callback vref.Deref -> - invalidUse <- true - Some expression - | Expr.Quote _ when isQuotationUsing callback expression -> - invalidUse <- true - Some expression + inlineDelegateInvoke (invokeRef, invokeTy, tyargs, rewrite arg, callRange) construction + |> Option.defaultWith (fun () -> failwith "unreachable: validated runtime-async delegate construction") + |> Some | _ -> None) PreInterceptBinding = None PostTransform = (fun _ -> None) @@ -528,12 +562,7 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r StackGuard = StackGuard("InlineRuntimeAsyncDelegateInvocation") } - let continuation = RewriteExpr rwenv continuation - - if invocations = 1 && not invalidUse then - Some continuation - else - None + RewriteExpr rwenv continuation let stackGuard = StackGuard("InlineRuntimeAsyncCallback") @@ -573,63 +602,39 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r else construction - let inlined = - if canCapture && isDelegate then - tryInlineDelegate callback construction continuation + let callbackUses = + if canCapture then + tryAnalyzeCallbackUses g callback isDelegate continuation else - None + ValueNone + + let inlined = + match callbackUses with + | ValueSome 1 when isDelegate && canInlineDelegateConstruction construction -> + Some(inlineDelegate callback construction continuation) + | _ -> None let callbackBranches = - if canCapture && Option.isNone inlined then + match callbackUses with + | ValueSome invocations when Option.isNone inlined -> tryDefunctionalizeCallback g m construction - else - None + |> Option.filter (fun branches -> + invocations = 0 + || List.sumBy _.NodeCount branches.Branches + <= maxInlinedCallbackCopySize / invocations) + | _ -> None match inlined, callbackBranches with | Some body, _ -> inlineCallbacks body | None, Some callbackBranches -> - let mutable invalidUse = false - let copySize = List.sumBy _.NodeCount callbackBranches.Branches - let mutable remainingCopySize = maxInlinedCallbackCopySize - let rwenv = { PreIntercept = Some(fun rewrite expression -> - let call = - match expression with - | Expr.App(Expr.Val(vref, _, _), _, [], [ arg ], callRange) when - not isDelegate && valEq callback vref.Deref - -> - Some(arg, callRange) - | DelegateInvokeExpr g (_, _, _, Expr.Val(vref, _, _), arg, callRange) when - isDelegate && valEq callback vref.Deref - -> - Some(arg, callRange) - | _ -> None - - match call with - | _ when invalidUse -> Some expression - | Some _ when copySize > remainingCopySize -> - invalidUse <- true - Some expression - | Some(arg, callRange) -> - remainingCopySize <- remainingCopySize - copySize - let arg = rewrite arg - - if invalidUse then - Some expression - else - Some(mkCallbackDispatch g callbackBranches arg (tyOfExpr g expression) callRange) - | None -> - match expression with - | Expr.Val(vref, _, _) when valEq callback vref.Deref -> - invalidUse <- true - Some expression - | Expr.Quote _ when isQuotationUsing callback expression -> - invalidUse <- true - Some expression - | _ -> None) + match tryCallbackInvocation g callback isDelegate expression with + | ValueSome(struct (arg, callRange)) -> + Some(mkCallbackDispatch g callbackBranches (rewrite arg) (tyOfExpr g expression) callRange) + | ValueNone -> None) PreInterceptBinding = None PostTransform = (fun _ -> None) RewriteQuotations = false @@ -638,18 +643,15 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r let continuation = RewriteExpr rwenv continuation - if invalidUse then - keep construction - else - let construction = - match point with - | DebugPointAtBinding.Yes point -> mkDebugPoint point callbackBranches.Construction - | _ -> callbackBranches.Construction + let construction = + match point with + | DebugPointAtBinding.Yes point -> mkDebugPoint point callbackBranches.Construction + | _ -> callbackBranches.Construction - let body = mkCompGenSequential m construction (inlineCallbacks continuation) + let body = mkCompGenSequential m construction (inlineCallbacks continuation) - (callbackBranches.Tag :: List.map snd callbackBranches.Captures, body) - ||> List.foldBack (fun v body -> mkCompGenLet m v (mkDefault (m, v.Type)) body) + (callbackBranches.Tag :: List.map snd callbackBranches.Captures, body) + ||> List.foldBack (fun v body -> mkCompGenLet m v (mkDefault (m, v.Type)) body) | None, None -> keep construction | _ -> keep construction | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (inlineCallbacks continuation) diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index b197d1ac8ee..ec15391d9a5 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1473,11 +1473,19 @@ let main _ = |> compileExeAndRun |> shouldSucceed -[] -[] +[] +[] +[] +[] [] -let ``runtime async does not rewrite delegates passed to opaque consumers`` (optimize: bool) = - FSharp """ +let ``runtime async does not rewrite delegates passed to opaque consumers`` (optimize: bool, quoted: bool) = + let opaqueUse = + if quoted then + "let saved = <@ callback.Invoke() @> in saved.ToString() |> ignore" + else + "consume callback |> ignore" + + FSharp $""" module RuntimeAsyncOpaqueDelegate open System.Threading.Tasks @@ -1490,10 +1498,13 @@ type Started<'T> = delegate of unit -> 'T let consume (callback: Started) = callback.Invoke() let inline invoke ([] callback: Started) = - StateMachineHelpers.__runtimeAsyncReturn(callback.Invoke() + consume callback) + StateMachineHelpers.__runtimeAsyncReturn( + let result = callback.Invoke() + {opaqueUse} + result) -let run (gate: Task) = - invoke (if true then Started(fun () -> AsyncHelpers.Await gate) else Started(fun () -> 0)) +let run (gate: Task) flag = + invoke (if flag then Started(fun () -> AsyncHelpers.Await gate) else Started(fun () -> 0)) """ |> withLangVersionPreview |> withFSharpCoreShippedNet @@ -2114,11 +2125,19 @@ let main _ = |> compileExeAndRun |> shouldSucceed -[] -[] +[] +[] +[] +[] [] -let ``runtime async does not rewrite opaque callback consumers`` (optimize: bool) = - FSharp """ +let ``runtime async does not rewrite opaque callback consumers`` (optimize: bool, quoted: bool) = + let opaqueUse = + if quoted then + "let saved = <@ callback() @> in saved.ToString() |> ignore" + else + "consume callback |> ignore" + + FSharp $""" module RuntimeAsyncOpaqueCallbackTest open System.Threading.Tasks @@ -2129,10 +2148,13 @@ open Microsoft.FSharp.Core.CompilerServices let consume (callback: unit -> int) = callback() let inline invoke ([] callback: unit -> int) = - StateMachineHelpers.__runtimeAsyncReturn (consume callback) + StateMachineHelpers.__runtimeAsyncReturn ( + let result = callback() + {opaqueUse} + result) -let run (gate: Task) = - invoke (if true then fun () -> AsyncHelpers.Await gate else fun () -> 0) +let run (gate: Task) flag = + invoke (if flag then fun () -> AsyncHelpers.Await gate else fun () -> 0) """ |> withLangVersionPreview |> withFSharpCoreShippedNet @@ -2428,7 +2450,10 @@ open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -let inline invoke ([] f: int -> int) = {calls} +let inline invoke ([] f: int -> int) = + let unrelated = <@ 42 @> + unrelated.ToString() |> ignore + {calls} let run (gate: Task) flag = StateMachineHelpers.__runtimeAsyncReturn ( From 67ce7354e0b17b7b3e3a27c5936d5fbc9a0a3b0b Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Thu, 8 Oct 2026 22:38:33 +0200 Subject: [PATCH 22/30] Share runtime-async debug wrappers and lower direct delegate receivers --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- docs/runtime-async.md | 8 ++ src/Compiler/Optimize/LowerRuntimeAsync.fs | 10 +- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 31 ++---- src/Compiler/TypedTree/RuntimeAsync.fs | 12 +++ src/Compiler/TypedTree/RuntimeAsync.fsi | 5 + .../Language/RuntimeAsyncTests.fs | 101 +++++++++++++++--- 7 files changed, 129 insertions(+), 40 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index c2a8aacf2a2..51ca08ffdff 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers, and reject suspensions left in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Inlined callback statements keep their own debug points, and captured mutable locals retain their lexical debugger scopes during construction and invocation without adding reference-cell allocations. Branch sizes and captures are cached, and callback uses, single-use delegate eligibility, and the existing copy budget are checked before expanding invocations. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers; also lower conditional receivers left directly on `Invoke` after optimization, while rejecting suspensions in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Shared debug-wrapper handling preserves callback statement points and captured mutable locals' lexical scopes during construction and invocation without adding reference-cell allocations. Branch sizes and captures are cached, and callback uses, single-use delegate eligibility, and the existing copy budget are checked before expanding invocations. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Prevent `InvalidProgramException` when runtime async awaits a `Task` upcast to `Task`, including through `ConfigureAwait`. ([Issue #20686](https://github.com/dotnet/fsharp/issues/20686), [PR #20726](https://github.com/dotnet/fsharp/pull/20726)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * Build parser diagnostic messages without runtime printf specialization or reflective function construction, so Native AOT hosts report indentation and invalid-character diagnostics instead of failing during formatting. Preserve `#r` reference tooltips while script project options remain alive, including after garbage collection or cache eviction. ([PR #20658](https://github.com/dotnet/fsharp/pull/20658)) diff --git a/docs/runtime-async.md b/docs/runtime-async.md index 41ee8e79ccd..08e067ccefc 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -191,6 +191,9 @@ For a residual `InlineIfLambda` delegate bound to a local, a single direct invocation can also be inlined through simple, effect-free conditional construction. Otherwise, directly rewritable invocations dispatch on the branch tag. Inner callback bindings are processed before the outer delegate. +If optimization leaves a construction expression directly as an `Invoke` +receiver, it is first bound to a compiler-owned callback and lowered through +the same path. Receiver construction still precedes argument evaluation. Effectful precomputations that capture a delegate's inputs are not moved to its invocation, so a pending operation is created once and repeated invokes share captured state. A delegate that escapes or is consumed opaquely cannot @@ -224,6 +227,11 @@ call site. User continuation arguments keep their own ranges, and the marked method retains a call-site sequence point even when forced inlining reduces its intermediate closures. +Callback lowering shares recognition and rebuilding of transparent debug +points and lexical-scope annotations. Rebuilding retains the original range, +scope name, and storage reference; a lexical scope is not discarded when +reducing an application through its wrapper. + Dead branches eliminated by optimization do not reach code generation and do not produce a suspension-outside-runtime-async diagnostic. diff --git a/src/Compiler/Optimize/LowerRuntimeAsync.fs b/src/Compiler/Optimize/LowerRuntimeAsync.fs index 08078891cef..caaac89a4e6 100644 --- a/src/Compiler/Optimize/LowerRuntimeAsync.fs +++ b/src/Compiler/Optimize/LowerRuntimeAsync.fs @@ -43,8 +43,7 @@ let private inlineCallbacks g implFile = let rec leadsToRuntimeAsyncReturn expr = match expr with | Expr.Let(_, rest, _, _) - | Expr.DebugPoint(_, rest) - | Expr.Op(TOp.DebugLocalScope _, [], [ rest ], _) + | RuntimeAsyncDebugWrapper rest | Expr.Sequential(_, rest, NormalSeq, _) -> leadsToRuntimeAsyncReturn rest | _ -> (TryGetRuntimeAsyncReturn g expr).IsSome @@ -73,6 +72,13 @@ let private inlineCallbacks g implFile = && leadsToRuntimeAsyncReturn continuation -> Some(mkLetBind m (TBind(callback, rewrite true construction, point)) (rewrite false continuation)) + | DelegateInvokeExpr g (invokeRef, invokeTy, tyargs, receiver, arg, m) when inContext && analyzer.ContainsSuspension receiver -> + let callback, callbackExpr = + mkCompGenLocal m "runtimeAsyncDelegate" (tyOfExpr g receiver) + + callback.SetInlineIfLambda() + let invoke = Expr.App(invokeRef, invokeTy, tyargs, [ callbackExpr; arg ], m) + Some(rewrite inContext (mkCompGenLet m callback receiver invoke)) | NewDelegateExpr g _ -> None | Expr.Obj _ when inContext -> Some(rewrite false expr) | _ -> None diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 6d2d5efcf6d..deb8a6ac7c0 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -212,10 +212,10 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt | _ -> false -> apply inner fty tyargs args m - | Expr.DebugPoint(point, inner), _ -> apply inner fty tyargs args m |> Option.map (fun r -> Expr.DebugPoint(point, r)) - | Expr.Op(TOp.DebugLocalScope _ as op, [], [ body ], mScope), _ -> + | Expr.DebugPoint(_, inner), _ -> apply inner fty tyargs args m |> Option.map (RebuildRuntimeAsyncDebugWrapper f) + | RuntimeAsyncDebugWrapper body, _ -> apply body (tyOfExpr g body) tyargs args m - |> Option.map (fun body -> Expr.Op(op, [], [ body ], mScope)) + |> Option.map (RebuildRuntimeAsyncDebugWrapper f) | Expr.Let(binding, body, mLet, _), _ -> apply body (tyOfExpr g body) tyargs args m |> Option.map (mkLetBind mLet binding) @@ -250,10 +250,7 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt PostTransform = (fun expression -> match expression with - | Expr.App((Expr.Lambda _ | Expr.Let _ | Expr.LetRec _ | Expr.Match _ | Expr.DebugPoint _ | Expr.Op(TOp.DebugLocalScope _, - _, - _, - _)) as f, + | Expr.App((Expr.Lambda _ | Expr.Let _ | Expr.LetRec _ | Expr.Match _ | RuntimeAsyncDebugWrapper _) as f, fty, tyargs, args, @@ -418,9 +415,7 @@ let private tryDefunctionalizeCallback (g: TcGlobals) m construction = } Some(List.foldBack (mkCompGenSequential m) assignments select) - | Expr.DebugPoint(point, body) -> defunctionalize body |> Option.map (fun body -> Expr.DebugPoint(point, body)) - | Expr.Op(TOp.DebugLocalScope _ as op, [], [ body ], m) -> - defunctionalize body |> Option.map (fun body -> Expr.Op(op, [], [ body ], m)) + | RuntimeAsyncDebugWrapper body -> defunctionalize body |> Option.map (RebuildRuntimeAsyncDebugWrapper expr) | Expr.Sequential(first, rest, NormalSeq, m) -> defunctionalize rest |> Option.map (fun rest -> Expr.Sequential(first, rest, NormalSeq, m)) @@ -486,8 +481,7 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r let rec tryDelegateSignature expr = match expr with | NewDelegateExpr g (_, [ parameter ], body, _, _) -> Some(parameter.Type, tyOfExpr g body) - | Expr.DebugPoint(_, rest) - | Expr.Op(TOp.DebugLocalScope _, [], [ rest ], _) + | RuntimeAsyncDebugWrapper rest | Expr.Sequential(_, rest, NormalSeq, _) | Expr.Let(_, rest, _, _) -> tryDelegateSignature rest | Expr.Match(_, _, _, targets, _, _) when targets.Length > 0 -> @@ -504,8 +498,7 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r let rec canInlineDelegateConstruction construction = match construction with | NewDelegateExpr g (_, [ _ ], _, _, _) -> true - | Expr.DebugPoint(_, rest) - | Expr.Op(TOp.DebugLocalScope _, [], [ rest ], _) -> canInlineDelegateConstruction rest + | RuntimeAsyncDebugWrapper rest -> canInlineDelegateConstruction rest | Expr.Sequential(first, rest, NormalSeq, _) when isTrivialValue first -> canInlineDelegateConstruction rest | Expr.Let(TBind(_, rhs, _), rest, _, _) when isTrivialValue rhs -> canInlineDelegateConstruction rest | Expr.Match(_, _, TDSwitch(Expr.Val(vref, _, _), [ TCase(_, TDSuccess([], _)) ], Some(TDSuccess([], _)), _), targets, _, _) when @@ -520,12 +513,9 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r match construction with | NewDelegateExpr g (_, [ _ ], _, _, _) -> Some(MakeFSharpDelegateInvokeAndTryBetaReduce g (invokeRef, construction, invokeTy, tyargs, arg, callRange)) - | Expr.DebugPoint(point, rest) -> + | RuntimeAsyncDebugWrapper rest -> inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest - |> Option.map (fun body -> Expr.DebugPoint(point, body)) - | Expr.Op(TOp.DebugLocalScope _ as op, [], [ rest ], m) -> - inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest - |> Option.map (fun body -> Expr.Op(op, [], [ body ], m)) + |> Option.map (RebuildRuntimeAsyncDebugWrapper construction) | Expr.Sequential(first, rest, NormalSeq, m) when isTrivialValue first -> inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest |> Option.map (fun body -> Expr.Sequential(first, body, NormalSeq, m)) @@ -655,8 +645,7 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r | None, None -> keep construction | _ -> keep construction | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (inlineCallbacks continuation) - | Expr.DebugPoint(point, inner) -> Expr.DebugPoint(point, inlineCallbacks inner) - | Expr.Op(TOp.DebugLocalScope _ as op, [], [ body ], m) -> Expr.Op(op, [], [ inlineCallbacks body ], m) + | RuntimeAsyncDebugWrapper body -> RebuildRuntimeAsyncDebugWrapper expr (inlineCallbacks body) | Expr.Sequential(first, rest, NormalSeq, m) -> Expr.Sequential(first, inlineCallbacks rest, NormalSeq, m) | _ -> expr diff --git a/src/Compiler/TypedTree/RuntimeAsync.fs b/src/Compiler/TypedTree/RuntimeAsync.fs index ff7983cf918..3dfab34312f 100644 --- a/src/Compiler/TypedTree/RuntimeAsync.fs +++ b/src/Compiler/TypedTree/RuntimeAsync.fs @@ -10,6 +10,18 @@ open FSharp.Compiler.TypedTreeOps type RuntimeAsyncReturnInfo = { Body: Expr; TypeArgs: TType list } +let (|RuntimeAsyncDebugWrapper|_|) expr = + match expr with + | Expr.DebugPoint(_, body) + | Expr.Op(TOp.DebugLocalScope _, [], [ body ], _) -> ValueSome body + | _ -> ValueNone + +let RebuildRuntimeAsyncDebugWrapper wrapper body = + match wrapper with + | Expr.DebugPoint(point, _) -> Expr.DebugPoint(point, body) + | Expr.Op(TOp.DebugLocalScope _ as op, [], [ _ ], m) -> Expr.Op(op, [], [ body ], m) + | _ -> failwith "unreachable: expression is not a runtime-async debug wrapper" + let (|RuntimeAsyncReturn|_|) (g: TcGlobals) (vref: ValRef) = valRefEq g vref g.cgh__runtimeAsyncReturn_vref || valRefEq g vref g.cgh__runtimeAsyncReturnValueTask_vref diff --git a/src/Compiler/TypedTree/RuntimeAsync.fsi b/src/Compiler/TypedTree/RuntimeAsync.fsi index f59d8909d7a..ba7fcea819f 100644 --- a/src/Compiler/TypedTree/RuntimeAsync.fsi +++ b/src/Compiler/TypedTree/RuntimeAsync.fsi @@ -9,6 +9,11 @@ open FSharp.Compiler.TypedTree type RuntimeAsyncReturnInfo = { Body: Expr; TypeArgs: TType list } +val (|RuntimeAsyncDebugWrapper|_|): expr: Expr -> Expr voption + +/// Retains the original debug range or lexical scope when rebuilding a matched wrapper. +val RebuildRuntimeAsyncDebugWrapper: wrapper: Expr -> body: Expr -> Expr + val TryGetRuntimeAsyncReturn: g: TcGlobals -> expr: Expr -> RuntimeAsyncReturnInfo option val (|RuntimeAsyncReturnFunction|_|): g: TcGlobals -> expr: Expr -> (ValRef * ValUseFlag * range) voption diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index ec15391d9a5..f221615d45f 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1614,49 +1614,67 @@ let work (ready: Task) = |> verifyPdb [ VerifyMethodSequencePoints("work", (if optimize then statementPoints else callSitePoint @ statementPoints)) ] [] -[] -[] -let ``runtime async conditional callback keeps mutable capture in its lexical scope`` (insideContext: bool) = - let source = """module MissingMutableLocal +[] +[] +[] +[] +let ``runtime async conditional callback keeps mutable capture in its lexical scope`` (insideContext: bool, asDelegate: bool) = + let callbackType, callbackCall, callbackStart, callbackEnd = + if asDelegate then "Started", "callback.Invoke()", "Started(", ")" + else "unit -> int", "callback ()", "", "" + + let source = $"""module MissingMutableLocal open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices - -let inline invoke ([] callback: unit -> int) : Task = - StateMachineHelpers.__runtimeAsyncReturn (callback ()) +type Started<'T> = delegate of unit -> 'T +let inline invoke ([] callback: {callbackType}) : Task = + StateMachineHelpers.__runtimeAsyncReturn ({callbackCall}) let run flag (ready: Task) = let mutable count = 99 invoke ( if flag then - printfn "outer = %d" count + printfn "outer = %%d" count let mutable count = 0 - printfn "%d" count - fun () -> + printfn "%%d" count + {callbackStart}fun () -> let value = AsyncHelpers.Await ready count <- count + value - count + count{callbackEnd} else - printfn "else = %d" count - fun () -> 0) + printfn "else = %%d" count + {callbackStart}fun () -> 0{callbackEnd}) """ let source = if insideContext then source .Replace(" : Task =", " =") - .Replace("StateMachineHelpers.__runtimeAsyncReturn (callback ())", "callback ()") + .Replace($"StateMachineHelpers.__runtimeAsyncReturn ({callbackCall})", callbackCall) .Replace("let run flag (ready: Task) =", "let run flag (ready: Task) = StateMachineHelpers.__runtimeAsyncReturn (") - .Replace("fun () -> 0)", "fun () -> 0))") + .Replace($"fun () -> 0{callbackEnd})", $"fun () -> 0{callbackEnd}))") else source + let source = source + """ +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let pending = run true gate.Task + if pending.IsCompleted then failwith "Callback did not suspend" + gate.SetResult 41 + if pending.GetAwaiter().GetResult() <> 41 then failwith "Mutable capture result changed" + if (run false gate.Task).GetAwaiter().GetResult() <> 0 then failwith "Other branch result changed" + 0 +""" let result = FSharp source + |> asExe |> withLangVersionPreview |> withFSharpCoreShippedNet |> withPortablePdb |> withNoOptimize - |> compile + |> compileExeAndRun |> shouldSucceed use stream = File.OpenRead(Path.ChangeExtension(result.OutputPath.Value, "pdb")) @@ -1718,6 +1736,57 @@ let run flag (ready: Task) = if insideContext then result |> verifyILNotPresent [ "FSharpRef" ] +[] +[] +[] +let ``runtime async evaluates a conditional delegate receiver before its argument`` (optimize: bool) = + FSharp """ +module RuntimeAsyncDelegateEvaluationOrder +open System.Collections.Generic +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +type Callback = delegate of int -> int + +let inline invoke ([] callback: Callback) (trace: ResizeArray) = + callback.Invoke(trace.Add "argument"; 3) + +let run (gate: Task) flag (trace: ResizeArray) = + StateMachineHelpers.__runtimeAsyncReturn ( + invoke ( + if flag then + trace.Add "construction" + let mutable count = 10 + Callback(fun x -> + let value = AsyncHelpers.Await gate + count <- count + value + x + count) + else + trace.Add "other" + Callback(fun x -> x * 2)) trace) + +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let trace = ResizeArray() + let pending = run gate.Task true trace + if pending.IsCompleted || trace.ToArray() <> [| "construction"; "argument" |] then + failwith "Receiver or argument evaluation order changed" + gate.SetResult 41 + if pending.GetAwaiter().GetResult() <> 54 then failwith "Mutable capture result changed" + trace.Clear() + if (run gate.Task false trace).GetAwaiter().GetResult() <> 6 + || trace.ToArray() <> [| "other"; "argument" |] then + failwith "Other branch evaluation changed" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + [] let ``runtime async suspension in exception region executes`` () = Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTasksAsyncDisposalException.fs") From cf97cb75b6273cd1bb37508f87ee550102afeaf5 Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Fri, 9 Oct 2026 07:37:05 +0200 Subject: [PATCH 23/30] Share runtime-async codegen preparation --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- docs/runtime-async.md | 11 +- src/Compiler/CodeGen/IlxGen.fs | 122 +++++++----------- .../Language/RuntimeAsyncTests.fs | 85 +++++++++--- 4 files changed, 125 insertions(+), 95 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 51ca08ffdff..050ba87d0ef 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers; also lower conditional receivers left directly on `Invoke` after optimization, while rejecting suspensions in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Shared debug-wrapper handling preserves callback statement points and captured mutable locals' lexical scopes during construction and invocation without adding reference-cell allocations. Branch sizes and captures are cached, and callback uses, single-use delegate eligibility, and the existing copy budget are checked before expanding invocations. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers; also lower conditional receivers left directly on `Invoke` after optimization, while rejecting suspensions in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Shared debug-wrapper handling preserves callback statement points and captured mutable locals' lexical scopes during construction and invocation without adding reference-cell allocations. Codegen shares marker preparation and closure validation. Branch sizes and captures are cached, and callback uses, single-use delegate eligibility, and the existing copy budget are checked before expanding invocations. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Prevent `InvalidProgramException` when runtime async awaits a `Task` upcast to `Task`, including through `ConfigureAwait`. ([Issue #20686](https://github.com/dotnet/fsharp/issues/20686), [PR #20726](https://github.com/dotnet/fsharp/pull/20726)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * Build parser diagnostic messages without runtime printf specialization or reflective function construction, so Native AOT hosts report indentation and invalid-character diagnostics instead of failing during formatting. Preserve `#r` reference tooltips while script project options remain alive, including after garbage collection or cache eviction. ([PR #20658](https://github.com/dotnet/fsharp/pull/20658)) diff --git a/docs/runtime-async.md b/docs/runtime-async.md index 08e067ccefc..72bd954c2d3 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -260,9 +260,10 @@ shared runtime-async boundary contract, which strips `DebugPoint` wrappers: assemblies do not define the enum member). 2. **Closure body** (`GenClosureAsLocalTypeFunction` and `GenClosureAsFirstClassFunction`): the same unwrapping marks the closure - `Invoke` method's IL body (`ILMethodBody.IsRuntimeAsync`). - `EraseClosures.convIlxClosureDef` copies that flag onto the emitted - method. + method's IL body (`ILMethodBody.IsRuntimeAsync`). Local type functions + mark `DirectInvoke` explicitly; for first-class closures, + `EraseClosures.convIlxClosureDef` copies the flag onto the emitted + `Invoke` method. 3. **Any other expression position** (`GenRuntimeAsyncReturnAsStartedTask`), e.g. a `let`-bound value initializer: the marker application is wrapped in a fresh `fun () -> ...` lambda that is immediately applied to `unit` and @@ -272,6 +273,10 @@ shared runtime-async boundary contract, which strips `DebugPoint` wrappers: emits a closure plus an indirect call); see the comment at `GenRuntimeAsyncReturnAsStartedTask`. +Named methods, closures, and sequence methods share marker unwrapping and +unit-return classification. Both closure emission paths also share captured +value validation, runtime-async environment setup, and IL-body marking. + A marker that ends up wrapped in anything other than `DebugPoint` at the top of a method or closure body is not detected there, but still reaches the catch-all case (3), so compilation stays correct — the cost is an extra diff --git a/src/Compiler/CodeGen/IlxGen.fs b/src/Compiler/CodeGen/IlxGen.fs index d4f6faea4b7..65462af27e4 100644 --- a/src/Compiler/CodeGen/IlxGen.fs +++ b/src/Compiler/CodeGen/IlxGen.fs @@ -7209,16 +7209,12 @@ and GenSequenceExpr ) let closeMethod = - let marker = TryGetRuntimeAsyncReturn g closeExpr - - let body = - marker |> Option.map (fun info -> info.Body) |> Option.defaultValue closeExpr - - let name = if marker.IsSome then "DisposeAsync" else "Close" + let struct (isRuntimeAsync, _, body) = GetRuntimeAsyncMethodBody g closeExpr + let name = if isRuntimeAsync then "DisposeAsync" else "Close" let methodEnv = { eenvinner with - inRuntimeAsyncMethod = marker.IsSome + inRuntimeAsyncMethod = isRuntimeAsync inInlineMethod = false } @@ -7226,11 +7222,11 @@ and GenSequenceExpr CodeGenMethodForExpr cenv cgbuf.mgbuf ([], name, methodEnv, 1, None, body, discardAndReturnVoid) |> fun code -> { code with - IsRuntimeAsync = marker.IsSome + IsRuntimeAsync = isRuntimeAsync } let resultTy = - if marker.IsSome then + if isRuntimeAsync then GenType cenv m eenvinner.tyenv (tyOfExpr g closeExpr) else ILType.Void @@ -7242,7 +7238,7 @@ and GenSequenceExpr mkILReturn resultTy, MethodBody.IL(InterruptibleLazy.FromValue ilCode) ) - |> fun methodDef -> methodDef.WithAsync(marker.IsSome) + |> fun methodDef -> methodDef.WithAsync(isRuntimeAsync) let checkCloseMethod = let ilCode = @@ -7261,35 +7257,29 @@ and GenSequenceExpr let eenvinner = eenvinner |> AddStorageForLocalVals g [ (nextEnumeratorValRef.Deref, Arg 1) ] - let marker = TryGetRuntimeAsyncReturn g generateNextExpr - - let body = - marker - |> Option.map (fun info -> info.Body) - |> Option.defaultValue generateNextExpr - - let name = if marker.IsSome then "MoveNextAsync" else "GenerateNext" + let struct (isRuntimeAsync, _, body) = GetRuntimeAsyncMethodBody g generateNextExpr + let name = if isRuntimeAsync then "MoveNextAsync" else "GenerateNext" let methodEnv = { eenvinner with - inRuntimeAsyncMethod = marker.IsSome + inRuntimeAsyncMethod = isRuntimeAsync inInlineMethod = false } let ilParams = - if marker.IsSome then + if isRuntimeAsync then [] else [ mkILParamNamed ("next", ILType.Byref ilCloEnumerableTy) ] let resultTy = - if marker.IsSome then + if isRuntimeAsync then GenType cenv m eenvinner.tyenv (tyOfExpr g generateNextExpr) else g.ilg.typ_Int32 let ilReturn = mkILReturn resultTy - let usedArgs = if marker.IsSome then 1 else 2 + let usedArgs = if isRuntimeAsync then 1 else 2 let ilCode = MethodBody.IL( @@ -7297,12 +7287,12 @@ and GenSequenceExpr CodeGenMethodForExpr cenv cgbuf.mgbuf ([], name, methodEnv, usedArgs, None, body, Return) |> fun code -> { code with - IsRuntimeAsync = marker.IsSome + IsRuntimeAsync = isRuntimeAsync }) ) mkILNonGenericVirtualInstanceMethod (name, ILMemberAccess.Public, ilParams, ilReturn, ilCode) - |> fun methodDef -> methodDef.WithAsync(marker.IsSome) + |> fun methodDef -> methodDef.WithAsync(isRuntimeAsync) let lastGeneratedMethod = let name = @@ -7488,6 +7478,36 @@ and CheckRuntimeAsyncFreeVars g m (cloinfo: IlxClosureInfo) = if fv.IsPinning || isByrefTy g fv.Type || isByrefLikeTy g m fv.Type then errorR (Error(FSComp.SR.chkByrefUsedInInvalidWay (richTextOfValName g fv), fv.Range)) +and GetRuntimeAsyncMethodBody g body = + match TryGetRuntimeAsyncReturn g body with + | Some info -> struct (true, List.isEmpty info.TypeArgs, info.Body) + | None -> struct (false, false, body) + +and GenClosureBody cenv (cgbuf: CodeGenBuffer) eenv (cloinfo: IlxClosureInfo) entryPointInfo body m = + let struct (isRuntimeAsync, isRuntimeAsyncUnit, body) = + GetRuntimeAsyncMethodBody cenv.g body + + if isRuntimeAsync then + CheckRuntimeAsyncFreeVars cenv.g m cloinfo + + let eenv = + { eenv with + inRuntimeAsyncMethod = isRuntimeAsync + } + + let sequel = if isRuntimeAsyncUnit then discardAndReturnVoid else Return + + let ilBody = + CodeGenMethodForExpr cenv cgbuf.mgbuf (entryPointInfo, cloinfo.cloName, eenv, 1, None, body, sequel) + + let ilBody = + if isRuntimeAsync then + { ilBody with IsRuntimeAsync = true } + else + ilBody + + struct (isRuntimeAsync, ilBody) + /// Generate a local type function contract class and implementation and GenClosureAsLocalTypeFunction cenv (cgbuf: CodeGenBuffer) eenv thisVars expr m = let g = cenv.g @@ -7515,28 +7535,8 @@ and GenClosureAsLocalTypeFunction cenv (cgbuf: CodeGenBuffer) eenv thisVars expr strip cloinfo.ilCloLambdas - let isRuntimeAsync, isRuntimeAsyncUnit, body = - match TryGetRuntimeAsyncReturn g body with - | Some info -> true, List.isEmpty info.TypeArgs, info.Body - | None -> false, false, body - - if isRuntimeAsync then - CheckRuntimeAsyncFreeVars g m cloinfo - - let eenvinner = - { eenvinner with - inRuntimeAsyncMethod = isRuntimeAsync - } - - let ilCloBody = - let sequel = if isRuntimeAsyncUnit then discardAndReturnVoid else Return - CodeGenMethodForExpr cenv cgbuf.mgbuf (entryPointInfo, cloinfo.cloName, eenvinner, 1, None, body, sequel) - - let ilCloBody = - if isRuntimeAsync then - { ilCloBody with IsRuntimeAsync = true } - else - ilCloBody + let struct (isRuntimeAsync, ilCloBody) = + GenClosureBody cenv cgbuf eenvinner cloinfo entryPointInfo body m let ilCtorBody = mkILMethodBody (true, [], 8, nonBranchingInstrsToCode (mkCallBaseConstructor (g.ilg.typ_Object, [])), None, eenv.imports) @@ -7583,28 +7583,8 @@ and GenClosureAsFirstClassFunction cenv (cgbuf: CodeGenBuffer) eenv thisVars m e let ilCloTypeRef = cloinfo.cloSpec.TypeRef - let isRuntimeAsync, isRuntimeAsyncUnit, body = - match TryGetRuntimeAsyncReturn g body with - | Some info -> true, List.isEmpty info.TypeArgs, info.Body - | None -> false, false, body - - if isRuntimeAsync then - CheckRuntimeAsyncFreeVars g m cloinfo - - let eenvinner = - { eenvinner with - inRuntimeAsyncMethod = isRuntimeAsync - } - - let ilCloBody = - let sequel = if isRuntimeAsyncUnit then discardAndReturnVoid else Return - CodeGenMethodForExpr cenv cgbuf.mgbuf (entryPointInfo, cloinfo.cloName, eenvinner, 1, None, body, sequel) - - let ilCloBody = - if isRuntimeAsync then - { ilCloBody with IsRuntimeAsync = true } - else - ilCloBody + let struct (_, ilCloBody) = + GenClosureBody cenv cgbuf eenvinner cloinfo entryPointInfo body m let cloTypeDefs = GenClosureTypeDefs @@ -10265,10 +10245,8 @@ and GenMethodForBinding | h :: t -> [ h ], t, true | _ -> [], methLambdaVars, false - let isRuntimeAsync, isRuntimeAsyncUnit, methLambdaBody = - match TryGetRuntimeAsyncReturn g methLambdaBody with - | Some info -> true, List.isEmpty info.TypeArgs, info.Body - | None -> false, false, methLambdaBody + let struct (isRuntimeAsync, isRuntimeAsyncUnit, methLambdaBody) = + GetRuntimeAsyncMethodBody g methLambdaBody if isRuntimeAsync then checkLanguageFeatureError g.langVersion LanguageFeature.RuntimeAsync m diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index f221615d45f..b162e20d90f 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -417,38 +417,85 @@ let ``runtime async compiles functions and members`` langVersion = |> compile |> shouldSucceed -[] -let ``runtime async supports Task and ValueTask return intrinsics`` () = - FSharp """ +[] +[] +[] +[] +[] +let ``runtime async supports Task and ValueTask return intrinsics`` (optimize: bool, asClosure: bool) = + let prefix, suffix = if asClosure then "invoke (fun () -> ", ")" else "", "" + let result = + FSharp $""" module RuntimeAsyncReturnShapesTest open System.Threading.Tasks +open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -let taskResult () : Task = - StateMachineHelpers.__runtimeAsyncReturn 1 +[] +let invoke callback = callback () + +[] +let taskResult (gate: Task) : Task = + {prefix}StateMachineHelpers.__runtimeAsyncReturn (AsyncHelpers.Await gate + 1){suffix} + +[] +let valueTaskResult (gate: Task) : ValueTask = + {prefix}StateMachineHelpers.__runtimeAsyncReturnValueTask (AsyncHelpers.Await gate + 1){suffix} + +[] +let taskUnit (gate: Task) : Task = + {prefix}StateMachineHelpers.__runtimeAsyncReturnUnit (AsyncHelpers.Await gate |> ignore){suffix} -let valueTaskResult () : ValueTask = - StateMachineHelpers.__runtimeAsyncReturnValueTask 1 +[] +let valueTaskUnit (gate: Task) : ValueTask = + {prefix}StateMachineHelpers.__runtimeAsyncReturnValueTaskUnit (AsyncHelpers.Await gate |> ignore){suffix} -let taskUnit () : Task = - StateMachineHelpers.__runtimeAsyncReturnUnit () +[] +let taskGenericUnit (gate: Task) : Task = + {prefix}StateMachineHelpers.__runtimeAsyncReturn (AsyncHelpers.Await gate |> ignore){suffix} -let valueTaskUnit () : ValueTask = - StateMachineHelpers.__runtimeAsyncReturnValueTaskUnit () +[] +let valueTaskGenericUnit (gate: Task) : ValueTask = + {prefix}StateMachineHelpers.__runtimeAsyncReturnValueTask (AsyncHelpers.Await gate |> ignore){suffix} [] let main _ = - taskUnit().Wait() - taskResult().Result |> ignore - valueTaskResult().Result |> ignore - valueTaskUnit().AsTask().Wait() + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let taskValue = taskResult gate.Task + let valueTaskValue = valueTaskResult gate.Task + let taskVoid = taskUnit gate.Task + let valueTaskVoid = valueTaskUnit gate.Task + let taskUnitValue = taskGenericUnit gate.Task + let valueTaskUnitValue = valueTaskGenericUnit gate.Task + if taskValue.IsCompleted || valueTaskValue.IsCompleted || taskVoid.IsCompleted + || valueTaskVoid.IsCompleted || taskUnitValue.IsCompleted || valueTaskUnitValue.IsCompleted then + failwith "Return marker did not suspend" + gate.SetResult 41 + if taskValue.GetAwaiter().GetResult() <> 42 || valueTaskValue.AsTask().GetAwaiter().GetResult() <> 42 then + failwith "Generic result changed" + taskVoid.GetAwaiter().GetResult() + valueTaskVoid.AsTask().GetAwaiter().GetResult() + taskUnitValue.GetAwaiter().GetResult() + valueTaskUnitValue.AsTask().GetAwaiter().GetResult() 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compileExeAndRun - |> shouldSucceed + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + result |> withMetadataReader (fun md -> + let asyncMethods = + [ for handle in md.MethodDefinitions do + let method = md.GetMethodDefinition handle + if int method.ImplAttributes &&& 0x2000 <> 0 then + yield md.GetString method.Name ] + let expected = + if asClosure then List.replicate 6 "Invoke" + else [ "taskResult"; "valueTaskResult"; "taskUnit"; "valueTaskUnit"; "taskGenericUnit"; "valueTaskGenericUnit" ] + Assert.Equal(List.sort expected, List.sort asyncMethods)) [] [] From 775a670b745b522e11f614f07cfb8ecf2f5d5879 Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:27:21 +0200 Subject: [PATCH 24/30] Guard runtime-async analysis and callback reconstruction stacks --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- docs/runtime-async.md | 7 ++ src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 70 +++++++++++++----- .../Language/RuntimeAsyncTests.fs | 71 +++++++++++++++++++ 4 files changed, 130 insertions(+), 20 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 050ba87d0ef..54145c84928 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers; also lower conditional receivers left directly on `Invoke` after optimization, while rejecting suspensions in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Shared debug-wrapper handling preserves callback statement points and captured mutable locals' lexical scopes during construction and invocation without adding reference-cell allocations. Codegen shares marker preparation and closure validation. Branch sizes and captures are cached, and callback uses, single-use delegate eligibility, and the existing copy budget are checked before expanding invocations. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers; also lower conditional receivers left directly on `Invoke` after optimization, while rejecting suspensions in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Shared debug-wrapper handling preserves callback statement points and captured mutable locals' lexical scopes during construction and invocation without adding reference-cell allocations. Codegen shares marker preparation and closure validation. Stack guards protect callback reconstruction, capture analysis, and suspension-flow analysis for deeply nested bodies. Branch sizes and captures are cached, and callback uses, single-use delegate eligibility, and the existing copy budget are checked before expanding invocations. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Prevent `InvalidProgramException` when runtime async awaits a `Task` upcast to `Task`, including through `ConfigureAwait`. ([Issue #20686](https://github.com/dotnet/fsharp/issues/20686), [PR #20726](https://github.com/dotnet/fsharp/pull/20726)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * Build parser diagnostic messages without runtime printf specialization or reflective function construction, so Native AOT hosts report indentation and invalid-character diagnostics instead of failing during formatting. Preserve `#r` reference tooltips while script project options remain alive, including after garbage collection or cache eviction. ([PR #20658](https://github.com/dotnet/fsharp/pull/20658)) diff --git a/docs/runtime-async.md b/docs/runtime-async.md index 72bd954c2d3..5e460d791c7 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -248,6 +248,13 @@ is not global: optimizer environments can provide different inline bodies, and optimization creates new expression trees. Context-dependent decisions such as `runtimeAsyncContext` remain outside the cached facts. +An optimizer analyzer lives for one application-inlining attempt; callback +lowering shares an analyzer for one implementation file, and exception +rewriting creates one per body. Flow-summary caches are also per body. +Custom recursive analysis and callback reconstruction use stack guards, +including traversals between inline-value bodies and through decision trees. +Callback capture analysis uses the guarded free-variable collector. + ## Code generation `IlxGen.fs` recognises the return-marker family in three placements through the diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index deb8a6ac7c0..3b94c76b3e2 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -33,6 +33,7 @@ type RuntimeAsyncAnalyzer(g: TcGlobals, getLambdaBody: ValRef -> Expr option) = let suspensionCache = Dictionary(HashIdentity.Reference) let valueCache = Dictionary() let visitingValues = HashSet() + let stackGuard = StackGuard("RuntimeAsyncAnalyzer") let rec containsValue (vref: ValRef) = match valueCache.TryGetValue vref.Stamp with @@ -54,6 +55,9 @@ type RuntimeAsyncAnalyzer(g: TcGlobals, getLambdaBody: ValRef -> Expr option) = result, complete and containsExpression expr = + stackGuard.Guard(fun () -> containsExpressionCore expr) + + and containsExpressionCore expr = match expressionCache.TryGetValue expr with | true, result -> result, true | _ -> @@ -91,6 +95,9 @@ type RuntimeAsyncAnalyzer(g: TcGlobals, getLambdaBody: ValRef -> Expr option) = member _.ContainsFragment expr = containsExpression expr |> fst member this.ContainsSuspension expr = + stackGuard.Guard(fun () -> this.ContainsSuspensionCore expr) + + member private this.ContainsSuspensionCore expr = match suspensionCache.TryGetValue expr with | true, result -> result | _ -> @@ -195,6 +202,8 @@ let private tryAnalyzeCallbackUses g callback isDelegate continuation = FoldExpr folder (ValueSome 0) continuation let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) expr = + let stackGuard = StackGuard("ReduceRuntimeAsyncReturnedClosureApplications") + let rec effectFree expr = match stripExpr expr with | Expr.Const _ @@ -205,6 +214,9 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt | _ -> false let rec apply f fty tyargs args m = + stackGuard.Guard(fun () -> applyCore f fty tyargs args m) + + and applyCore f fty tyargs args m = match f, args with | Expr.DebugPoint(_, inner), _ when match stripDebugPoints inner with @@ -232,13 +244,7 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt apply body (tyOfExpr g body) tyargs args m) | Expr.Lambda(_, _, _, [ _ ], _, _, _), first :: (_ :: _ as rest) when List.forall effectFree rest -> let reduced = MakeApplicationAndBetaReduce g (f, fty, [ tyargs ], [ first ], m) - - let rec applyRest expr = - match expr with - | Expr.Let(binding, body, mLet, _) -> applyRest body |> Option.map (mkLetBind mLet binding) - | _ -> apply expr (tyOfExpr g expr) [] rest m - - applyRest reduced + apply reduced (tyOfExpr g reduced) [] rest m | Expr.Lambda _, _ :: _ -> Some(MakeApplicationAndBetaReduce g (f, fty, [ tyargs ], args, m)) | _ when not (analyzer.ContainsSuspension f) && List.forall effectFree args -> Some(mkAppsAux g f fty [ tyargs ] args m) | _ -> None @@ -257,7 +263,7 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt m) when not args.IsEmpty && analyzer.ContainsSuspension expression -> apply f fty tyargs args m | _ -> None) RewriteQuotations = false - StackGuard = StackGuard("ReduceRuntimeAsyncReturnedClosureApplications") + StackGuard = stackGuard } RewriteExpr rwenv expr @@ -358,18 +364,22 @@ let private exprNodeCount expr = /// Replaces each lambda in tail position of a callback construction with an assignment of its branch tag /// and captured locals, or returns None if a tail is not a supported single-argument lambda. -let private tryDefunctionalizeCallback (g: TcGlobals) m construction = - let constructionFree = (freeInExpr CollectLocals construction).FreeLocals +let private tryDefunctionalizeCallback (g: TcGlobals) (stackGuard: StackGuard) m construction = + let freeVarOptions = CollectLocalsWithStackGuard() + let constructionFree = (freeInExpr freeVarOptions construction).FreeLocals let tag, _ = mkMutableCompGenLocal m "runtimeAsyncCallbackTag" g.int32_ty let branches = ResizeArray() let captures = Dictionary() let rec defunctionalize expr = + stackGuard.Guard(fun () -> defunctionalizeCore expr) + + and defunctionalizeCore expr = match expr with | Expr.Lambda(_, None, None, [ parameter ], body, m, _) | NewDelegateExpr g (_, [ parameter ], body, m, _) -> let captured = - (freeInExpr CollectLocals body).FreeLocals + (freeInExpr freeVarOptions body).FreeLocals |> Zset.elements |> List.filter (fun v -> not (valEq v parameter) && not (Zset.contains v constructionFree)) @@ -443,7 +453,7 @@ let private maxInlinedCallbackCopySize = 2000 /// Inlines an invocation of a defunctionalized callback: the argument is bound once, then each branch body /// is copied with its parameter and captured locals remapped. -let private mkCallbackDispatch (g: TcGlobals) (callback: CallbackBranches) arg resultTy m = +let private mkCallbackDispatch (g: TcGlobals) (stackGuard: StackGuard) (callback: CallbackBranches) arg resultTy m = let argVal, _ = mkCompGenLocal m "runtimeAsyncCallbackArg" (tyOfExpr g arg) let captureRemap = @@ -467,6 +477,9 @@ let private mkCallbackDispatch (g: TcGlobals) (callback: CallbackBranches) arg r body) let rec dispatch i branches = + stackGuard.Guard(fun () -> dispatchCore i branches) + + and dispatchCore i branches = match branches with | [ last ] -> branch last | first :: rest -> @@ -478,6 +491,9 @@ let private mkCallbackDispatch (g: TcGlobals) (callback: CallbackBranches) arg r mkCompGenLet m argVal arg (dispatch 0 callback.Branches) let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext (expr: Expr) = + let stackGuard = StackGuard("InlineRuntimeAsyncCallback") + let freeVarOptions = CollectLocalsWithStackGuard() + let rec tryDelegateSignature expr = match expr with | NewDelegateExpr g (_, [ parameter ], body, _, _) -> Some(parameter.Type, tyOfExpr g body) @@ -496,6 +512,9 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r | _ -> false let rec canInlineDelegateConstruction construction = + stackGuard.Guard(fun () -> canInlineDelegateConstructionCore construction) + + and canInlineDelegateConstructionCore construction = match construction with | NewDelegateExpr g (_, [ _ ], _, _, _) -> true | RuntimeAsyncDebugWrapper rest -> canInlineDelegateConstruction rest @@ -510,6 +529,9 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r // Construction moves to the invocation, so its evaluation must not have observable effects. let rec inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) construction = + stackGuard.Guard(fun () -> inlineDelegateInvokeCore (invokeRef, invokeTy, tyargs, arg, callRange) construction) + + and inlineDelegateInvokeCore (invokeRef, invokeTy, tyargs, arg, callRange) construction = match construction with | NewDelegateExpr g (_, [ _ ], _, _, _) -> Some(MakeFSharpDelegateInvokeAndTryBetaReduce g (invokeRef, construction, invokeTy, tyargs, arg, callRange)) @@ -549,13 +571,11 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r PreInterceptBinding = None PostTransform = (fun _ -> None) RewriteQuotations = false - StackGuard = StackGuard("InlineRuntimeAsyncDelegateInvocation") + StackGuard = stackGuard } RewriteExpr rwenv continuation - let stackGuard = StackGuard("InlineRuntimeAsyncCallback") - let rec inlineCallbacks expr = stackGuard.Guard(fun () -> inlineCallbacksCore expr) @@ -580,7 +600,7 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r match shape with | Some(argTy, resultTy, isDelegate) when canInline && not (isByrefLikeTy g m argTy || isByrefLikeTy g m resultTy) -> - let freeVals = (freeInExpr CollectLocals construction).FreeLocals + let freeVals = (freeInExpr freeVarOptions construction).FreeLocals let canCapture = freeVals @@ -607,7 +627,7 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r let callbackBranches = match callbackUses with | ValueSome invocations when Option.isNone inlined -> - tryDefunctionalizeCallback g m construction + tryDefunctionalizeCallback g stackGuard m construction |> Option.filter (fun branches -> invocations = 0 || List.sumBy _.NodeCount branches.Branches @@ -623,7 +643,15 @@ let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) r Some(fun rewrite expression -> match tryCallbackInvocation g callback isDelegate expression with | ValueSome(struct (arg, callRange)) -> - Some(mkCallbackDispatch g callbackBranches (rewrite arg) (tyOfExpr g expression) callRange) + Some( + mkCallbackDispatch + g + stackGuard + callbackBranches + (rewrite arg) + (tyOfExpr g expression) + callRange + ) | ValueNone -> None) PreInterceptBinding = None PostTransform = (fun _ -> None) @@ -703,12 +731,13 @@ let private TryGetRuntimeAsyncNonPreservableAlias (g: TcGlobals) expr = let private analyzeRuntimeAsyncExpr (g: TcGlobals) expr = let cache = Dictionary(HashIdentity.Reference) + let stackGuard = StackGuard("AnalyzeRuntimeAsyncFlow") let rec analyzeExpr expr = match cache.TryGetValue expr with | true, summary -> summary | _ -> - let summary = analyzeExprCore expr + let summary = stackGuard.Guard(fun () -> analyzeExprCore expr) cache[expr] <- summary summary @@ -884,6 +913,9 @@ let private analyzeRuntimeAsyncExpr (g: TcGlobals) expr = analyzeExpr targetExpr |> removeRuntimeAsyncBoundVals boundVals let rec analyzeTree tree = + stackGuard.Guard(fun () -> analyzeTreeCore tree) + + and analyzeTreeCore tree = match tree with | TDSuccess(results, targetNum) -> let resultSummary = diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index b162e20d90f..39b43945426 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -2544,6 +2544,77 @@ let main _ = |> compileExeAndRun |> shouldSucceed +[] +[] +[] +[] +[] +let ``runtime async lowers deeply nested callback constructions`` (optimize: bool, asDelegate: bool) = + let depth = 1500 + let statements = String.replicate depth " touch ()\n" + let callbackType, invocation, wrap = + if asDelegate then + "Callback", "callback.Invoke", fun body -> $"Callback(fun x -> {body})" + else + "int -> int", "callback", fun body -> $"(fun x -> {body})" + + let source = $""" +module RuntimeAsyncDeepConstruction +open System +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +type Callback = delegate of int -> int +let mutable constructions = 0 +[] +let touch () = constructions <- constructions + 1 +let inline twice ([] callback: {callbackType}) = {invocation} 1 + {invocation} 2 + +[] +let run (gate: Task) flag = + StateMachineHelpers.__runtimeAsyncReturn ( + twice ( +{statements} let offset = 7 + if flag then {wrap "AsyncHelpers.Await gate + offset + x"} + else {wrap "offset + x"})) + +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let pending = run gate.Task true + if pending.IsCompleted then failwith "Expected pending result" + if constructions <> {depth} then failwith "Construction was not evaluated once" + gate.SetResult 41 + if pending.GetAwaiter().GetResult() <> 99 then failwith "Suspending branch changed" + if (run gate.Task false).Result <> 17 then failwith "Non-suspending branch changed" + if constructions <> {2 * depth} then failwith "Construction was repeated" + 0 +""" + let result = + FSharp source + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + // In-process compilation can have a larger stack than command-line compilation. + let outputDirectory = result.Output.OutputPath |> Option.get |> Path.GetDirectoryName + let sourcePath = Path.Combine(outputDirectory, "DeepConstruction.fs") + let outputPath = Path.Combine(outputDirectory, "DeepConstruction.exe") + File.WriteAllText(sourcePath, source) + let cliResult = + runFscProcess [ + yield! CompilerAssert.DefaultProjectOptions(Utilities.TargetFramework.FSharpCoreShippedNet).OtherOptions + yield "--target:exe" + yield "--langversion:preview" + yield if optimize then "--optimize+" else "--optimize-" + yield $"-o:\"{outputPath}\"" + yield $"\"{sourcePath}\"" + ] + Assert.True(cliResult.ExitCode = 0, $"{cliResult.StdOut}\n{cliResult.StdErr}") + [] [] [] From 006b843ad87f61e4b5ecf960341672348c39e56b Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Fri, 9 Oct 2026 08:50:29 +0200 Subject: [PATCH 25/30] Organize runtime-async tests and update design documentation --- docs/runtime-async.md | 53 +- .../Language/RuntimeAsyncEdgeCaseTests.fs | 5 +- .../Language/RuntimeAsyncTests.fs | 3974 +++++++++-------- 3 files changed, 2029 insertions(+), 2003 deletions(-) diff --git a/docs/runtime-async.md b/docs/runtime-async.md index 5e460d791c7..995e561eb24 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -48,12 +48,14 @@ instructions, including before `ConfigureAwait`. These casts prevent the runtime from incorrectly optimizing a generic task-producing call followed by a non-generic await. -Known runtime restrictions (currently **not** diagnosed by the F# compiler): - -* `tail.` and `localloc` are forbidden. -* generated suspension points cannot occur inside exception-handling regions. - Awaiting in a protected `try` body now works on the current runtime. Direct - intrinsic bodies rewrite suspending `try/with` handlers and filters, and +Runtime restrictions and their compiler handling: + +* `tail.` is forbidden; runtime-async methods suppress tail-call prefixes. +* `localloc` is forbidden and reported as FS3920, even without a suspension. +* `MethodImplOptions.Synchronized` is rejected with FS3921. +* Suspension cannot occur inside exception handlers, filters, or `finally` + blocks. Awaiting in a protected `try` body is supported. Return-marker + bodies rewrite suspending `try/with` handlers and filters, and `try/finally` compensations, so the suspension runs outside the EH region. C# avoids this by rewriting EH-region awaits at lowering time (see the @@ -64,8 +66,11 @@ Known runtime restrictions (currently **not** diagnosed by the F# compiler): runs `DisposeAsync` (possibly suspending) *outside* the `try`, then restores the pending exception. This makes `use` on an `IAsyncDisposable` work under runtime async. + Byref, byref-like, and pinned locals that are used after a suspension are -rejected with diagnostic FS3917. +rejected with diagnostic FS3919 when their non-preservable provenance is +tracked. Pinned provenance is not currently propagated through ref cells or +captured delegates, so those uses are not reliably diagnosed. Calls to `AsyncHelpers` suspension methods emitted outside a runtime-async method are rejected during code generation. Explicitly `inline` method bodies @@ -85,14 +90,14 @@ val __runtimeAsyncReturnUnit : unit -> System.Threading.Tasks.Task val __runtimeAsyncReturnValueTaskUnit : unit -> System.Threading.Tasks.ValueTask ``` -Their FSharp.Core implementations throw; the compiler consumes every -occurrence before code generation, so those bodies are never executed. They -are marked `NoInlining` so a missed consumption does not silently fold into a -caller. +Their FSharp.Core implementations throw; code generation consumes recognized +marker applications instead of emitting calls to those stubs. They are marked +`NoInlining` so a missed consumption does not silently fold into a caller. -The feature is gated on `langversion:preview` -(`LanguageFeature.RuntimeAsync`) and on the target reference assemblies -exposing `MethodImplOptions.Async` (see "Runtime capability check" below). +The feature is available from F# 11.2, including `default`, `latest`, and +`preview` (`LanguageFeature.RuntimeAsync`), and gated on the target reference +assemblies exposing `MethodImplOptions.Async` (see "Runtime capability check" +below). Without the language version the checker reports error 3350; without runtime support it reports 3351. @@ -140,7 +145,7 @@ declaration. `Optimizer.fs` preserves the marker application as-is, optimizing its argument. The marked expression is forced to `HasEffect = true` and -`UnknownValue`, so the optimizer never inlines, duplicates, or discards it. +`UnknownValue`, so ordinary value propagation cannot treat it as a pure result. The marker therefore survives optimization as an ordinary `Expr.App` node; nothing else in the typed tree records that a method is runtime-async. @@ -403,12 +408,12 @@ Builder-generated `MoveNextAsync` can lose visible sequence points. The optimize This is a proposal, not a drop-in TaskSeq replacement. Producer `try/with` is lowered through an ordinary nested sequence, while runtime-async suspensions inside that handler remain unsupported. Opaque recipes and optimized tail handoff are rejected. Early disposal retains ordinary sequence exception precedence: an outer cleanup failure replaces an inner cleanup failure. Concurrent move/dispose calls are unsupported. Awaiting a non-cancellable operation does not make it cancellable. Performance qualification must include genuinely pending operations, not only completed awaits. -Build and run the small [usage and lifecycle example](../tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsync/RuntimeAsyncSequence.fs) with the matching preview SDK: +Build and run the small [usage and lifecycle example](../tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsync/RuntimeAsyncSequence.fs) with the matching SDK: ```sh ./build.sh -c Release dotnet test --project tests/FSharp.Compiler.ComponentTests/FSharp.Compiler.ComponentTests.fsproj \ - -c Release --no-build --filter-class 'Language.RuntimeAsyncSequenceTests' + -c Release --no-build -- --filter-class 'Language.RuntimeAsyncSequenceTests' ``` The tests compile the library and consumer together and separately, with optimization enabled and disabled. @@ -418,20 +423,20 @@ The tests compile the library and consumer together and separately, with optimiz An inline fragment that escapes as a first-class value, is passed to a non-inline function, or is dynamically dispatched cannot be preserved as a runtime-async suspension fragment. If the suspension remains in the generated -non-runtime-async method, code generation reports FS3916 rather than emitting +non-runtime-async method, code generation reports FS3918 rather than emitting an unsafe closure. Fragments in statically eliminated branches do not trigger this diagnostic. ## Not yet implemented -* Complete diagnostics for runtime restrictions. The optimizer rewrites +* Complete diagnostics for runtime restrictions. Runtime-async lowering rewrites suspending `try/with` handlers and filters, and `try/finally` compensations, so they execute outside exception-handling regions. There is no general diagnostic for runtime-contract violations in other generated or imported - shapes, and `localloc` has no dedicated diagnostic. Runtime-async methods - suppress `tail.` emission rather than reporting it. + shapes. Stack allocation has a dedicated diagnostic (FS3920), and + runtime-async methods suppress `tail.` emission rather than reporting it. * A builder in FSharp.Core; builders using the feature are currently application/library code. -* Compile-time enforcement that the marker was actually consumed before - code generation (a missed marker throws only when its FSharp.Core stub is - reached at run time, or produces invalid IL as described above). +* Compile-time enforcement that every marker use was consumed during code + generation (a missed marker throws only when its FSharp.Core stub is reached + at run time). diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncEdgeCaseTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncEdgeCaseTests.fs index a15d77fee7c..af2c6ef57be 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncEdgeCaseTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncEdgeCaseTests.fs @@ -12,9 +12,6 @@ module Language.RuntimeAsyncEdgeCaseTests // sources (single async method, clean assembly), because ILChecker's NotPresent check is // assembly-scoped and the CE builder's own inline members legitimately contain `tail.`/`MoveNext`. // The CE `Run` lowers `do!`/`let!` to exactly this intrinsic form (see the execution facts). -// -// The "undiagnosed forbidden pattern" facts below pin restrictions that docs/runtime-async.md -// records as known and currently NOT diagnosed by the F# compiler (tail./localloc forbidden). open Xunit open FSharp.Test.Compiler @@ -26,7 +23,7 @@ let private builderPath = Path.Combine(runtimeAsyncDir, "RuntimeTaskBuilder.fs") // Builds a minimal direct-intrinsic compilation unit: the module header plus the opens every // StateMachineHelpers.__runtimeAsyncReturn body needs, then the supplied one-liner body. Used for the -// shape / absence / undiagnosed-pattern assertions, which must run on a single-method assembly. +// IL-shape and diagnostic assertions that need a single-method assembly. let private directIntrinsicSource body = String.concat "\n" [ "module M" diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 39b43945426..4cac3dde524 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1,13 +1,14 @@ -module Language.RuntimeAsyncTests +namespace Language -open Xunit -open FSharp.Test -open FSharp.Test.Compiler -open System.IO -open System.Reflection.Metadata -open System.Text.RegularExpressions +module RuntimeAsyncTests = + open Xunit + open FSharp.Test + open FSharp.Test.Compiler + open System.IO + open System.Reflection.Metadata + open System.Text.RegularExpressions -let private runtimeAsyncSource = """ + let private runtimeAsyncSource = """ module RuntimeAsyncTest open System.Threading.Tasks @@ -32,7 +33,7 @@ type Calculator() = StateMachineHelpers.__runtimeAsyncReturn (x + 1) """ -let private runtimeAsyncRawSource = """ + let private runtimeAsyncRawSource = """ module RuntimeAsyncRawTest open System.Threading.Tasks @@ -82,7 +83,7 @@ type Calculator() = """ #if NETCOREAPP -let private nestedTaskSource = """module NestedTask + let private nestedTaskSource = """module NestedTask open System.Threading.Tasks open System.Runtime.CompilerServices @@ -97,65 +98,65 @@ let run (ready: Task) : Task = AsyncHelpers.Await child) """ -[] -[] -[] -[] -[] -[] -[] -[] -[] -let ``Issue 20576 rejects runtime Await in ordinary task methods`` (optimize: bool, shape: string) = - let configure = withLangVersionPreview >> withFSharpCoreShippedNet >> withOptimization optimize - let helper = "let inline awaitValue (ready: Task) = System.Runtime.CompilerServices.AsyncHelpers.Await ready" - let source, references = - match shape with - | "direct" -> nestedTaskSource, [] - | "local" -> - nestedTaskSource - .Replace("let child = task {", $"{helper}\n let child = task {{") - .Replace("let value = AsyncHelpers.Await ready", "let value = awaitValue ready"), [] - | "imported" -> - let library = FSharp($"module AwaitLibrary\nopen System.Threading.Tasks\n{helper}") |> asLibrary |> withName "AwaitLibrary" |> configure - library |> compile |> shouldSucceed |> ignore - nestedTaskSource.Replace("let value = AsyncHelpers.Await ready", "let value = AwaitLibrary.awaitValue ready"), [ library ] - | "unit" -> - nestedTaskSource.Replace("Task", "Task").Replace("let value = AsyncHelpers.Await ready", "AsyncHelpers.Await ready").Replace("return value + 1", "return ()"), [] - | _ -> failwith $"Unexpected shape: {shape}" - let result = - FSharp source - |> asLibrary - |> configure - |> withReferences references - |> compile - result |> shouldFail |> withErrorCode 3918 |> ignore - let ranges = - match optimize, shape with - // Optimized task-template inlining attributes the call to the inner task keyword. - | true, "local" -> [10, 21, 25] - | true, _ -> [9, 21, 25] - | false, "direct" -> [10, 25, 49] - | false, "local" -> [9, 52, 108; 11, 25, 41] - | false, "imported" -> [10, 25, 54] - | false, "unit" -> [10, 13, 37] - | _ -> failwith $"Unexpected shape: {shape}" - Assert.Equal(ranges.Length, result.Output.Diagnostics.Length) - Assert.Equal(ranges.Length, result.Output.PerFileErrors.Length) - result - |> withDiagnostics [ - for line, startCol, endCol in ranges -> - Error 3918, Line line, Col startCol, Line line, Col endCol, - "Runtime async suspension method 'Await' may only be called from a runtime async method." - ] - |> ignore - -[] -[] -[] -let ``Issue 20576 preserves ordinary task composition`` (optimize: bool) = - let result = - FSharp """ + [] + [] + [] + [] + [] + [] + [] + [] + [] + let ``Issue 20576 rejects runtime Await in ordinary task methods`` (optimize: bool, shape: string) = + let configure = withLangVersionPreview >> withFSharpCoreShippedNet >> withOptimization optimize + let helper = "let inline awaitValue (ready: Task) = System.Runtime.CompilerServices.AsyncHelpers.Await ready" + let source, references = + match shape with + | "direct" -> nestedTaskSource, [] + | "local" -> + nestedTaskSource + .Replace("let child = task {", $"{helper}\n let child = task {{") + .Replace("let value = AsyncHelpers.Await ready", "let value = awaitValue ready"), [] + | "imported" -> + let library = FSharp($"module AwaitLibrary\nopen System.Threading.Tasks\n{helper}") |> asLibrary |> withName "AwaitLibrary" |> configure + library |> compile |> shouldSucceed |> ignore + nestedTaskSource.Replace("let value = AsyncHelpers.Await ready", "let value = AwaitLibrary.awaitValue ready"), [ library ] + | "unit" -> + nestedTaskSource.Replace("Task", "Task").Replace("let value = AsyncHelpers.Await ready", "AsyncHelpers.Await ready").Replace("return value + 1", "return ()"), [] + | _ -> failwith $"Unexpected shape: {shape}" + let result = + FSharp source + |> asLibrary + |> configure + |> withReferences references + |> compile + result |> shouldFail |> withErrorCode 3918 |> ignore + let ranges = + match optimize, shape with + // Optimized task-template inlining attributes the call to the inner task keyword. + | true, "local" -> [10, 21, 25] + | true, _ -> [9, 21, 25] + | false, "direct" -> [10, 25, 49] + | false, "local" -> [9, 52, 108; 11, 25, 41] + | false, "imported" -> [10, 25, 54] + | false, "unit" -> [10, 13, 37] + | _ -> failwith $"Unexpected shape: {shape}" + Assert.Equal(ranges.Length, result.Output.Diagnostics.Length) + Assert.Equal(ranges.Length, result.Output.PerFileErrors.Length) + result + |> withDiagnostics [ + for line, startCol, endCol in ranges -> + Error 3918, Line line, Col startCol, Line line, Col endCol, + "Runtime async suspension method 'Await' may only be called from a runtime async method." + ] + |> ignore + + [] + [] + [] + let ``Issue 20576 preserves ordinary task composition`` (optimize: bool) = + let result = + FSharp """ module TaskComposition open System open System.Threading.Tasks @@ -206,45 +207,45 @@ let main _ = if not observed then failwith "Missing unit side effect" 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - result |> withMetadataReader (fun md -> - let methods = [ for handle in md.MethodDefinitions -> md.GetMethodDefinition handle ] - let generatedNames = ["MoveNext"; "SetStateMachine"; "get_ResumptionPoint"; "get_Data"; "set_Data"] - for name in generatedNames do - let method = methods |> List.filter (fun method -> md.GetString method.Name = name) |> Assert.Single - Assert.Equal(0, int method.ImplAttributes &&& 0x2000) - if name = "MoveNext" then - let mutable signature = md.GetBlobReader method.Signature - Assert.False(signature.ReadSignatureHeader().IsGeneric) - Assert.Equal(0, signature.ReadCompressedInteger()) - Assert.Equal(System.Reflection.Metadata.SignatureTypeCode.Void, signature.ReadSignatureTypeCode()) - for name in ["child"; "run"] do - let method = methods |> List.filter (fun method -> md.GetString method.Name = name) |> Assert.Single - Assert.Equal(0x2000, int method.ImplAttributes &&& 0x2000) + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + result |> withMetadataReader (fun md -> + let methods = [ for handle in md.MethodDefinitions -> md.GetMethodDefinition handle ] + let generatedNames = ["MoveNext"; "SetStateMachine"; "get_ResumptionPoint"; "get_Data"; "set_Data"] + for name in generatedNames do + let method = methods |> List.filter (fun method -> md.GetString method.Name = name) |> Assert.Single + Assert.Equal(0, int method.ImplAttributes &&& 0x2000) + if name = "MoveNext" then + let mutable signature = md.GetBlobReader method.Signature + Assert.False(signature.ReadSignatureHeader().IsGeneric) + Assert.Equal(0, signature.ReadCompressedInteger()) + Assert.Equal(System.Reflection.Metadata.SignatureTypeCode.Void, signature.ReadSignatureTypeCode()) + for name in ["child"; "run"] do + let method = methods |> List.filter (fun method -> md.GetString method.Name = name) |> Assert.Single + Assert.Equal(0x2000, int method.ImplAttributes &&& 0x2000) + if optimize then + let liftedChild = + methods |> List.filter (fun method -> md.GetString method.Name = "Invoke" && int method.ImplAttributes &&& 0x2000 <> 0) + Assert.Single(liftedChild) |> ignore) + let _, _, il = ILChecker.verifyILAndReturnActual [] result.OutputPath.Value [] + let methodBody name = + Regex.Match(il, @"(?ms)^(?[ \t]*)\.method[^{}]*\b" + name + @"(?:<[^>]+>)?\([^{}]*\{.*?^\k\}").Value + let moveNext = methodBody "MoveNext" + Assert.NotEmpty(moveNext) + Assert.Contains("void", moveNext) + Assert.DoesNotContain("AsyncHelpers::Await", moveNext) if optimize then - let liftedChild = - methods |> List.filter (fun method -> md.GetString method.Name = "Invoke" && int method.ImplAttributes &&& 0x2000 <> 0) - Assert.Single(liftedChild) |> ignore) - let _, _, il = ILChecker.verifyILAndReturnActual [] result.OutputPath.Value [] - let methodBody name = - Regex.Match(il, @"(?ms)^(?[ \t]*)\.method[^{}]*\b" + name + @"(?:<[^>]+>)?\([^{}]*\{.*?^\k\}").Value - let moveNext = methodBody "MoveNext" - Assert.NotEmpty(moveNext) - Assert.Contains("void", moveNext) - Assert.DoesNotContain("AsyncHelpers::Await", moveNext) - if optimize then - Assert.Contains("AsyncHelpers::Await", methodBody "Invoke") - Assert.Contains("FSharpFunc`2", moveNext) - else - Assert.Contains("TaskComposition::child", moveNext) - for name in ["child"; "run"] do - Assert.Contains("AsyncHelpers::Await", methodBody name) + Assert.Contains("AsyncHelpers::Await", methodBody "Invoke") + Assert.Contains("FSharpFunc`2", moveNext) + else + Assert.Contains("TaskComposition::child", moveNext) + for name in ["child"; "run"] do + Assert.Contains("AsyncHelpers::Await", methodBody name) -let private runtimeAsyncCrossAssemblyLibrary = """ + let private runtimeAsyncCrossAssemblyLibrary = """ module RuntimeAsyncCrossAssemblyLibrary open System.Threading.Tasks @@ -257,7 +258,7 @@ let inline getValue () : Task = 42) """ -let private runtimeAsyncCrossAssemblyConsumer = """ + let private runtimeAsyncCrossAssemblyConsumer = """ module RuntimeAsyncCrossAssemblyConsumer open RuntimeAsyncCrossAssemblyLibrary @@ -267,12 +268,12 @@ let main _ = if getValue().GetAwaiter().GetResult() = 42 then 0 else 1 """ -[] -[] -[] -let ``runtime async rejects pinning from an imported inline function after suspension`` (optimize: bool) = - let library = - FSharp """ + [] + [] + [] + let ``runtime async rejects pinning from an imported inline function after suspension`` (optimize: bool) = + let library = + FSharp """ module RuntimeAsyncPinningCrossAssemblyLibrary open FSharp.NativeInterop @@ -283,12 +284,12 @@ let inline comparePin (array: byte[]) ([] action) = use after = fixed array struct (NativePtr.toNativeInt before, NativePtr.toNativeInt after) """ - |> withName "RuntimeAsyncPinningCrossAssemblyLibrary" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withNoWarn 9 + |> withName "RuntimeAsyncPinningCrossAssemblyLibrary" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withNoWarn 9 - FSharp """ + FSharp """ module RuntimeAsyncPinningCrossAssemblyConsumer open System.Threading.Tasks @@ -300,34 +301,34 @@ let run (data: byte[]) (gate: Task) = StateMachineHelpers.__runtimeAsyncReturn ( comparePin data (fun () -> AsyncHelpers.Await gate)) """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> withNoWarn 9 - |> withReferences [ library ] - |> compile - |> shouldFail - |> withErrorCode 3919 - -[] -[] -[] -let ``runtime async methods execute across assemblies`` (optimize: bool) = - FSharp runtimeAsyncCrossAssemblyConsumer - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> withReferences [ - FSharp runtimeAsyncCrossAssemblyLibrary - |> withName "RuntimeAsyncCrossAssemblyLibrary" |> withLangVersionPreview |> withFSharpCoreShippedNet - ] - |> compileExeAndRun - |> shouldSucceed + |> withOptimization optimize + |> withNoWarn 9 + |> withReferences [ library ] + |> compile + |> shouldFail + |> withErrorCode 3919 + + [] + [] + [] + let ``runtime async methods execute across assemblies`` (optimize: bool) = + FSharp runtimeAsyncCrossAssemblyConsumer + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> withReferences [ + FSharp runtimeAsyncCrossAssemblyLibrary + |> withName "RuntimeAsyncCrossAssemblyLibrary" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + ] + |> compileExeAndRun + |> shouldSucceed #endif -let private runtimeAsyncNestedInlineSource = """ + let private runtimeAsyncNestedInlineSource = """ module RuntimeAsyncNestedInlineTest open System.Threading.Tasks @@ -345,9 +346,9 @@ let f (task: Task) : Task = """ #if NETCOREAPP -[] -let ``runtime async is unavailable in FSharp 11.0`` () = - FSharp """ + [] + let ``runtime async is unavailable in FSharp 11.0`` () = + FSharp """ module RuntimeAsyncPreviewTest open System.Threading.Tasks @@ -356,15 +357,15 @@ open Microsoft.FSharp.Core.CompilerServices let f : Task = StateMachineHelpers.__runtimeAsyncReturn 1 """ - |> withLangVersion11 - |> withFSharpCoreShippedNet - |> compile - |> shouldFail - |> withErrorCode 3350 + |> withLangVersion11 + |> withFSharpCoreShippedNet + |> compile + |> shouldFail + |> withErrorCode 3350 -[] -let ``runtime async suspension outside runtime async is rejected`` () = - FSharp """ + [] + let ``runtime async suspension outside runtime async is rejected`` () = + FSharp """ module RuntimeAsyncSuspensionContextTest open System.Threading.Tasks @@ -375,15 +376,15 @@ let f () = AsyncHelpers.AwaitAwaiter(Task.Delay(1).GetAwaiter()) AsyncHelpers.UnsafeAwaitAwaiter(Task.Delay(1).GetAwaiter()) """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compile - |> shouldFail - |> withErrorCodes [ 3918; 3918; 3918 ] + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compile + |> shouldFail + |> withErrorCodes [ 3918; 3918; 3918 ] -[] -let ``runtime async rejects non Task result carriers`` () = - FSharp """ + [] + let ``runtime async rejects non Task result carriers`` () = + FSharp """ module RuntimeAsyncCarrierTest open Microsoft.FSharp.Core.CompilerServices @@ -391,41 +392,41 @@ open Microsoft.FSharp.Core.CompilerServices let f : string = StateMachineHelpers.__runtimeAsyncReturn "result" """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compile - |> shouldFail - |> withErrorCode 1 + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compile + |> shouldFail + |> withErrorCode 1 -[] -let ``runtime async intrinsic does not capture user-defined same-named values`` () = - FSharp """ + [] + let ``runtime async intrinsic does not capture user-defined same-named values`` () = + FSharp """ let __runtimeAsyncReturn value = value let result = __runtimeAsyncReturn 1 """ - |> typecheck - |> shouldSucceed - -[] -[] -[] -[] -let ``runtime async compiles functions and members`` langVersion = - FSharp runtimeAsyncSource - |> withLangVersion langVersion - |> withFSharpCoreShippedNet - |> compile - |> shouldSucceed - -[] -[] -[] -[] -[] -let ``runtime async supports Task and ValueTask return intrinsics`` (optimize: bool, asClosure: bool) = - let prefix, suffix = if asClosure then "invoke (fun () -> ", ")" else "", "" - let result = - FSharp $""" + |> typecheck + |> shouldSucceed + + [] + [] + [] + [] + let ``runtime async compiles functions and members`` langVersion = + FSharp runtimeAsyncSource + |> withLangVersion langVersion + |> withFSharpCoreShippedNet + |> compile + |> shouldSucceed + + [] + [] + [] + [] + [] + let ``runtime async supports Task and ValueTask return intrinsics`` (optimize: bool, asClosure: bool) = + let prefix, suffix = if asClosure then "invoke (fun () -> ", ")" else "", "" + let result = + FSharp $""" module RuntimeAsyncReturnShapesTest open System.Threading.Tasks @@ -480,41 +481,41 @@ let main _ = valueTaskUnitValue.AsTask().GetAwaiter().GetResult() 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - - result |> withMetadataReader (fun md -> - let asyncMethods = - [ for handle in md.MethodDefinitions do - let method = md.GetMethodDefinition handle - if int method.ImplAttributes &&& 0x2000 <> 0 then - yield md.GetString method.Name ] - let expected = - if asClosure then List.replicate 6 "Invoke" - else [ "taskResult"; "valueTaskResult"; "taskUnit"; "valueTaskUnit"; "taskGenericUnit"; "valueTaskGenericUnit" ] - Assert.Equal(List.sort expected, List.sort asyncMethods)) - -[] -[] -[] -[")>] -[")>] -[] -[] -[")>] -[")>] -let ``Issue 20686 - await upcast Task`` (optimize: bool, carrier: string) = - let marker, value, toTask = - match carrier with - | "Task" -> "__runtimeAsyncReturnUnit", "()", "" - | "Task" -> "__runtimeAsyncReturn", "42", "" - | "ValueTask" -> "__runtimeAsyncReturnValueTaskUnit", "()", ".AsTask()" - | "ValueTask" -> "__runtimeAsyncReturnValueTask", "42", ".AsTask()" - | _ -> failwith $"Unexpected carrier: {carrier}" - FSharp $""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + result |> withMetadataReader (fun md -> + let asyncMethods = + [ for handle in md.MethodDefinitions do + let method = md.GetMethodDefinition handle + if int method.ImplAttributes &&& 0x2000 <> 0 then + yield md.GetString method.Name ] + let expected = + if asClosure then List.replicate 6 "Invoke" + else [ "taskResult"; "valueTaskResult"; "taskUnit"; "valueTaskUnit"; "taskGenericUnit"; "valueTaskGenericUnit" ] + Assert.Equal(List.sort expected, List.sort asyncMethods)) + + [] + [] + [] + [")>] + [")>] + [] + [] + [")>] + [")>] + let ``Issue 20686 - await upcast Task`` (optimize: bool, carrier: string) = + let marker, value, toTask = + match carrier with + | "Task" -> "__runtimeAsyncReturnUnit", "()", "" + | "Task" -> "__runtimeAsyncReturn", "42", "" + | "ValueTask" -> "__runtimeAsyncReturnValueTaskUnit", "()", ".AsTask()" + | "ValueTask" -> "__runtimeAsyncReturnValueTask", "42", ".AsTask()" + | _ -> failwith $"Unexpected carrier: {carrier}" + FSharp $""" module TaskUpcasts open System @@ -580,169 +581,15 @@ let main _ = | _ -> failwith "Unexpected outcome" 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -let ``runtime async supports inlining of a lambda`` () = - FSharp """ -module RuntimeAsyncInlineLambdaTest - -open System.Threading.Tasks -open System.Runtime.CompilerServices -open Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers - -let inline makeFragment () = - fun x -> - AsyncHelpers.Await (Task.Delay 1000) - printfn "Hello from async function with input: %d" x - -let inline consume([] f) = - __runtimeAsyncReturn(f 42) - -[] -let main _ = - consume (makeFragment()) |> _.Result |> ignore - 0 -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compileExeAndRun - |> shouldSucceed - -[] -let ``runtime async supports inlining of a multi argument lambda`` () = - FSharp """ -module RuntimeAsyncInlineMultiArgumentLambdaTest - -open System.Threading.Tasks -open System.Runtime.CompilerServices -open Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers - -let inline makeFragment () = - fun x y -> - AsyncHelpers.Await (Task.Delay 1) - x + y - -let inline consume([] f) = - __runtimeAsyncReturn(f 40 2) - -[] -let main _ = - if (consume (makeFragment())).Result <> 42 then 1 else 0 -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compileExeAndRun - |> shouldSucceed - -[] -let ``runtime async fuses suspension in inline returned closures`` () = - FSharp """ -module RuntimeAsyncInlineReturnedClosureTest - -open System -open System.Threading.Tasks -open System.Runtime.CompilerServices -open Microsoft.FSharp.Core.CompilerServices - -type Code = obj -> unit - -type Builder() = - member inline _.Delay([] generator: unit -> Code) : Code = - fun state -> generator() state - - member inline _.Zero() : Code = - fun _ -> () - - member inline _.Yield(_: int) : Code = - fun _ -> () - - member inline _.Bind(task: Task, [] continuation: unit -> Code) : Code = - fun state -> - AsyncHelpers.Await task - continuation() state - - member inline _.Combine(first: Code, [] second: Code) : Code = - fun state -> - first state - second state - - member inline _.Run([] code: Code) : Task = - StateMachineHelpers.__runtimeAsyncReturnUnit (code null) - -[] -let main _ = - let builder = Builder() - builder { - yield 1 - do! Task.Delay(1) - } - |> _.Wait() - 0 -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -[] -[] -let ``runtime async fuses returned closures across recursive bindings`` (optimize: bool, nestedRuntimeAsync: bool) = - let loopBody = - if nestedRuntimeAsync then - "__runtimeAsyncReturn (if count = 0 then AsyncHelpers.Await ready else AsyncHelpers.Await (loop (count - 1)))" - else - "if count = 0 then ready else loop (count - 1)" - - FSharp $""" -module RuntimeAsyncRecursiveReturnedClosureTest - -open System.Threading.Tasks -open System.Runtime.CompilerServices -open Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers - -let mutable constructions = 0 -let mutable invocations = 0 - -let inline runBody ([] body: unit -> unit -> int) = - __runtimeAsyncReturn (body () ()) - -let run (ready: Task) = - runBody (fun () -> - constructions <- constructions + 1 - let rec loop count : Task = - {loopBody} - fun () -> - invocations <- invocations + 1 - AsyncHelpers.Await (loop 2)) - -[] -let main _ = - let ready = TaskCompletionSource() - let result = run ready.Task - if result.IsCompleted || constructions <> 1 || invocations <> 1 then - failwith "Callback evaluation or suspension was lost" - ready.SetResult 42 - if result.GetAwaiter().GetResult() <> 42 || constructions <> 1 || invocations <> 1 then - failwith "Recursive callback returned an incorrect result" - 0 -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed -[] -let ``runtime async ignores unreachable suspension`` () = - FSharp """ + [] + let ``runtime async ignores unreachable suspension`` () = + FSharp """ module RuntimeAsyncUnreachableSuspensionTest open System.Threading.Tasks @@ -757,16 +604,16 @@ let main _ = f () 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compileExeAndRun - |> shouldSucceed + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compileExeAndRun + |> shouldSucceed -[] -[] -[] -let ``runtime async preserves reraise after a suspending handler`` (optimize: bool) = - FSharp """ + [] + [] + [] + let ``runtime async preserves reraise after a suspending handler`` (optimize: bool) = + FSharp """ module RuntimeAsyncReraiseTest open System @@ -792,56 +639,56 @@ let main _ = | e when e.Message = "boom" -> 0 | _ -> 1 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -let private checkReraiseOwnership optimized mode = - let methods = - match mode with - | "CONTROLS" -> [ "synchronousSelection", false; "synchronousCleanup", false; "filteredReraise", false; "Await", false ] - | "PRIMARY" -> [ "recover", true ] - | _ -> - [ "recover", true; "recoverString", true; "recoverValue", true; "innerOwner", true - "selection", true; "cleanup", true ] - let result = - FsFromPath(Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncReraiseOwnership.fs")) |> withLangVersionPreview |> withFSharpCoreShippedNet - |> withOptimization optimized - |> withDefines [mode] - |> asExe - |> compile + |> withOptimization optimize + |> compileExeAndRun |> shouldSucceed - result - |> verifyRuntimeAsyncExceptionRegions (methods |> List.map (fun (name, awaits) -> $"Reraise::{name}", awaits)) - |> run - |> shouldSucceed - -[] -[] -[] -let ``Issue 20575 runtime async nested reraise ownership`` optimized = - checkReraiseOwnership optimized "PRIMARY" - -[] -[] -[] -let ``Issue 20575 runtime async ownership matrix`` optimized = - checkReraiseOwnership optimized "MATRIX" - -[] -[] -[] -let ``Issue 20575 legal synchronous exception region controls`` optimized = - checkReraiseOwnership optimized "CONTROLS" - -let private compileInspectionProbe (body: string) = - // C# leaves these calls in their EH regions; these libraries must never be executed. - CSharp $""" + let private checkReraiseOwnership optimized mode = + let methods = + match mode with + | "CONTROLS" -> [ "synchronousSelection", false; "synchronousCleanup", false; "filteredReraise", false; "Await", false ] + | "PRIMARY" -> [ "recover", true ] + | _ -> + [ "recover", true; "recoverString", true; "recoverValue", true; "innerOwner", true + "selection", true; "cleanup", true ] + let result = + FsFromPath(Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncReraiseOwnership.fs")) + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimized + |> withDefines [mode] + |> asExe + |> compile + |> shouldSucceed + + result + |> verifyRuntimeAsyncExceptionRegions (methods |> List.map (fun (name, awaits) -> $"Reraise::{name}", awaits)) + |> run + |> shouldSucceed + + [] + [] + [] + let ``Issue 20575 runtime async nested reraise ownership`` optimized = + checkReraiseOwnership optimized "PRIMARY" + + [] + [] + [] + let ``Issue 20575 runtime async ownership matrix`` optimized = + checkReraiseOwnership optimized "MATRIX" + + [] + [] + [] + let ``Issue 20575 legal synchronous exception region controls`` optimized = + checkReraiseOwnership optimized "CONTROLS" + + let private compileInspectionProbe (body: string) = + // C# leaves these calls in their EH regions; these libraries must never be executed. + CSharp $""" using System; using System.Runtime.CompilerServices; using System.Threading.Tasks; @@ -854,61 +701,61 @@ public static class Inspection }} }} """ - |> withName "Inspection" - |> asLibrary - |> compile - |> shouldSucceed - -[] -[] -[] -[] -[] -[] -[] -[] -[] -let ``Issue 20575 inspection rejects suspension in exception regions`` body forbidden = - let result = compileInspectionProbe body - if forbidden then - let error = - Assert.Throws(fun () -> - result |> verifyRuntimeAsyncExceptionRegions ["Inspection::Probe", false] |> ignore) - Assert.StartsWith("Inspection::Probe: suspension in exception handler/filter at IL_", error.Message) - Assert.Contains("regions (kind, try offset/length, handler offset/length, filter offset):", error.Message) - else - result |> verifyRuntimeAsyncExceptionRegions ["Inspection::Probe", false] |> ignore - -[] -[] -[] -[] -let ``Issue 20575 inspection rejects missing probes and suspension`` methodName requiresAwait message = - let result = - FSharp (runtimeAsyncSource + "\ntype AbstractProbe = abstract MissingBody: unit -> unit\n") - |> withLangVersionPreview - |> withFSharpCoreShippedNet + |> withName "Inspection" |> asLibrary |> compile |> shouldSucceed - let error = - Assert.Throws(fun () -> - result |> verifyRuntimeAsyncExceptionRegions [methodName, requiresAwait] |> ignore) - Assert.Equal($"{message}{methodName}", error.Message) - -[] -let ``Issue 20575 inspection requires runtime async metadata even with suspension`` () = - let result = compileInspectionProbe "AsyncHelpers.Await(audit);" - result |> verifyRuntimeAsyncExceptionRegions ["Inspection::Probe", false] |> ignore - let error = - Assert.Throws(fun () -> - result |> verifyRuntimeAsyncExceptionRegions ["Inspection::Probe", true] |> ignore) - Assert.Equal("Missing runtime-async body with suspension in Inspection::Probe", error.Message) - -[] -let ``runtime async rejects stackalloc across suspension`` () = - FSharp """ + [] + [] + [] + [] + [] + [] + [] + [] + [] + let ``Issue 20575 inspection rejects suspension in exception regions`` body forbidden = + let result = compileInspectionProbe body + if forbidden then + let error = + Assert.Throws(fun () -> + result |> verifyRuntimeAsyncExceptionRegions ["Inspection::Probe", false] |> ignore) + Assert.StartsWith("Inspection::Probe: suspension in exception handler/filter at IL_", error.Message) + Assert.Contains("regions (kind, try offset/length, handler offset/length, filter offset):", error.Message) + else + result |> verifyRuntimeAsyncExceptionRegions ["Inspection::Probe", false] |> ignore + + [] + [] + [] + [] + let ``Issue 20575 inspection rejects missing probes and suspension`` methodName requiresAwait message = + let result = + FSharp (runtimeAsyncSource + "\ntype AbstractProbe = abstract MissingBody: unit -> unit\n") + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> asLibrary + |> compile + |> shouldSucceed + + let error = + Assert.Throws(fun () -> + result |> verifyRuntimeAsyncExceptionRegions [methodName, requiresAwait] |> ignore) + Assert.Equal($"{message}{methodName}", error.Message) + + [] + let ``Issue 20575 inspection requires runtime async metadata even with suspension`` () = + let result = compileInspectionProbe "AsyncHelpers.Await(audit);" + result |> verifyRuntimeAsyncExceptionRegions ["Inspection::Probe", false] |> ignore + let error = + Assert.Throws(fun () -> + result |> verifyRuntimeAsyncExceptionRegions ["Inspection::Probe", true] |> ignore) + Assert.Equal("Missing runtime-async body with suspension in Inspection::Probe", error.Message) + + [] + let ``runtime async rejects stackalloc across suspension`` () = + FSharp """ module RuntimeAsyncStackallocTest open System.Threading.Tasks @@ -923,15 +770,15 @@ let f () : Task = AsyncHelpers.Await(Task.Delay 1) NativePtr.read p) """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compile - |> shouldFail - |> withErrorCode 3920 + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compile + |> shouldFail + |> withErrorCode 3920 -[] -let ``runtime async rejects stackalloc without suspension`` () = - FSharp """ + [] + let ``runtime async rejects stackalloc without suspension`` () = + FSharp """ module RuntimeAsyncStackallocWithoutSuspensionTest open System.Threading.Tasks @@ -945,15 +792,15 @@ let f () : Task = NativePtr.write p 42 NativePtr.read p) """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compile - |> shouldFail - |> withErrorCode 3920 + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compile + |> shouldFail + |> withErrorCode 3920 -[] -let ``runtime async rejects a byref captured by an inlined closure`` () = - FSharp """ + [] + let ``runtime async rejects a byref captured by an inlined closure`` () = + FSharp """ module RuntimeAsyncByrefClosureTest open System.Threading.Tasks @@ -964,62 +811,15 @@ let f (x: byref) : Task = let y = x StateMachineHelpers.__runtimeAsyncReturn (x + y) """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compile - |> shouldFail - |> withErrorCode 406 - -[] -[] -[] -let ``runtime async does not duplicate effectful InlineIfLambda arguments`` (optimize: bool) = - FSharp """ -module RuntimeAsyncInlineIfLambdaEffectsTest - -open System.Threading.Tasks -open System.Runtime.CompilerServices -open Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers - -let mutable calls = 0 - -let effect () = - calls <- calls + 1 - fun () -> 1 - -let inline plainTwice ([] f: unit -> int) = f () + f () -let inline twice ([] f: unit -> int) = - __runtimeAsyncReturn (f () + f ()) -let inline unused ([] f: unit -> int) = - __runtimeAsyncReturn 20 - -[] -let main _ = - let plainResult = plainTwice (effect ()) - let plainCalls = calls - calls <- 0 - let twiceResult = (twice (effect ())).GetAwaiter().GetResult() - let twiceCalls = calls - calls <- 0 - let unusedResult = (unused (effect ())).GetAwaiter().GetResult() - let unusedCalls = calls - - if plainResult = 2 && plainCalls = 1 - && twiceResult = 2 && twiceCalls = 1 - && unusedResult = 20 && unusedCalls = 1 then - 0 - else - 1 -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compile + |> shouldFail + |> withErrorCode 406 -[] -let ``runtime async pipe syntax is gated by the language version`` () = - FSharp """ + [] + let ``runtime async pipe syntax is gated by the language version`` () = + FSharp """ module RuntimeAsyncPipeGateTest open System.Threading.Tasks @@ -1028,21 +828,575 @@ open Microsoft.FSharp.Core.CompilerServices let f (x: int) : Task = x |> StateMachineHelpers.__runtimeAsyncReturn """ - |> withLangVersion90 - |> withFSharpCoreShippedNet - |> compile - |> shouldFail - |> withErrorCode 3350 - -[] -[] -[] -let ``runtime async preserves evaluation order for curried inline applications`` (optimize: bool) = - FSharp """ -module RuntimeAsyncCurriedApplicationTest + |> withLangVersion90 + |> withFSharpCoreShippedNet + |> compile + |> shouldFail + |> withErrorCode 3350 -open System.Threading.Tasks -open Microsoft.FSharp.Core.CompilerServices + [] + let ``runtime async rejects synchronized methods`` () = + FSharp """ +module RuntimeAsyncSynchronizedTest + +open System +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +[] +let f () : Task = + StateMachineHelpers.__runtimeAsyncReturn ( + AsyncHelpers.Await(Task.Delay(1).ContinueWith(fun _ -> 1))) +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compile + |> shouldFail + |> withErrorCode 3921 + + [] + let ``runtime async combines awaited chunks without delegates`` () = + FSharp runtimeAsyncRawSource + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compile + |> verifyILContains [ + "Task::Delay(int32)" + "AsyncHelpers::Await(class [runtime]System.Threading.Tasks.Task)" + ] + |> shouldSucceed + + [] + let ``runtime async specializes nested inline suspensions without optimization`` () = + FSharp runtimeAsyncNestedInlineSource + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withNoOptimize + |> compile + |> verifyILContains [ "AsyncHelpers::Await(class [runtime]System.Threading.Tasks.Task`1)" ] + + [] + [] + [] + let ``runtime task builder fixture executes through runtime async`` (optimize: bool) = + FsFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTaskBuilder.fs")) + |> withAdditionalSourceFile ( + SourceFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTasks.fs")) + ) + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + [] + let ``runtime task AsyncLocal values propagate through runtime async`` () = + FsFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTaskBuilder.fs")) + |> withAdditionalSourceFile ( + SourceFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncAsyncLocal.fs")) + ) + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compileExeAndRun + |> shouldSucceed + + [] + let ``runtime async direct intrinsic fixture executes`` () = + Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncBasic.fs") + |> FsFromPath + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + let ``runtime async low level async enumerable fixture executes`` (optimize: bool) = + Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncEnumerableLowLevel.fs") + |> FsFromPath + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + let ``runtime async enumerable builder fixture executes`` (optimize: bool) = + FsFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTaskBuilder.fs")) + |> withAdditionalSourceFile ( + SourceFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncEnumerable.fs")) + ) + |> withAdditionalSourceFile ( + SourceFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncEnumerableTests.fs")) + ) + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + [] + let ``runtime async suspension in exception region executes`` () = + Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTasksAsyncDisposalException.fs") + |> FsFromPath + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compileExeAndRun + |> shouldSucceed + + [] + let ``runtime async reraise preserves the innermost exception after suspension`` () = + FSharp """ +module RuntimeAsyncReraiseTest + +open System +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let f () : Task = + StateMachineHelpers.__runtimeAsyncReturn ( + try + raise (InvalidOperationException("outer")) + with _ -> + AsyncHelpers.Await(Task.Delay(1)) + try + raise (ArgumentException("inner")) + with _ -> + reraise ()) + +[] +let main _ = + try + f().GetAwaiter().GetResult() |> ignore + 1 + with + | :? ArgumentException as ex when ex.Message = "inner" -> 0 + | _ -> 1 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + let ``runtime async preserves an outer reraise after a nested suspension`` (optimize: bool) = + FSharp """ +module RuntimeAsyncNestedReraiseTest + +open System +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let run (gate: Task) (outer: exn) (trace: ResizeArray) : Task = + StateMachineHelpers.__runtimeAsyncReturn ( + try + raise outer + with _ -> + AsyncHelpers.Await gate + try + reraise () + finally + trace.Add "finally") + +[] +let main _ = + let gate = TaskCompletionSource() + let trace = ResizeArray() + let outer = InvalidOperationException("outer") + let work = run gate.Task outer trace + gate.SetResult(()) + + try + work.GetAwaiter().GetResult() |> ignore + 1 + with + | ex when obj.ReferenceEquals(ex, outer) && trace.ToArray() = [| "finally" |] -> 0 + | _ -> 1 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + let ``runtime async rejects suspension in an unmarked object member`` (optimize: bool) = + FSharp """ +module RuntimeAsyncUnmarkedMemberTest + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +type IInt = + abstract Get : unit -> int + +let make (gate: Task) = + StateMachineHelpers.__runtimeAsyncReturn ( + { new IInt with + member _.Get() = AsyncHelpers.Await gate }) +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compile + |> shouldFail + |> withErrorCode 3918 + + [] + [] + [] + let ``runtime async marker inside an object member remains supported`` (optimize: bool) = + FSharp """ +module RuntimeAsyncMarkedObjectMemberTest + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +type IInt = + abstract Get : unit -> Task + +let make (gate: Task) = + { new IInt with + member _.Get() = + StateMachineHelpers.__runtimeAsyncReturn (AsyncHelpers.Await gate) } + +[] +let main _ = + let value = (make (Task.FromResult 41)).Get().GetAwaiter().GetResult() + if value = 41 then 0 else 1 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + let ``runtime async evaluates a suspending exception filter once`` (optimize: bool) = + FSharp """ +module RuntimeAsyncExceptionFilterTest + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let run (gate: Task) (predicate: unit -> bool) : Task = + StateMachineHelpers.__runtimeAsyncReturn ( + try + failwith "body" + with _ when (AsyncHelpers.Await gate; predicate()) -> + 7) + +[] +let main _ = + let gate = TaskCompletionSource() + let mutable calls = 0 + + let firstTrue () = + calls <- calls + 1 + calls = 1 + + let work = run gate.Task firstTrue + gate.SetResult(()) + + try + if work.GetAwaiter().GetResult() = 7 && calls = 1 then 0 else 1 + with _ -> + 1 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + [] + let ``runtime async is unavailable in FSharp 9.0 when optimization is disabled`` () = + FSharp """ +module RuntimeAsyncNoOptimizePreviewTest + +open System.Threading.Tasks +open Microsoft.FSharp.Core.CompilerServices + +let f (x: int) : Task = + x |> StateMachineHelpers.__runtimeAsyncReturn +""" + |> withLangVersion90 + |> withFSharpCoreShippedNet + |> withNoOptimize + |> compile + |> shouldFail + |> withErrorCode 3350 + + [] + let ``runtime async rejects suspension inside an ordinary sequence`` () = + FSharp """ +module RuntimeAsyncNestedSequenceTest + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let f (gate: Task) : Task = + StateMachineHelpers.__runtimeAsyncReturn ( + seq { + let value = AsyncHelpers.Await gate + yield value + yield value + 1 + }) +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compile + |> shouldFail + |> withErrorCode 3918 + +#else + [] + let ``runtime async intrinsic is only available in the shipped net FSharp.Core`` () = + FSharp """ +open System.Threading.Tasks +open Microsoft.FSharp.Core.CompilerServices + +let f : Task = + StateMachineHelpers.__runtimeAsyncReturn 1 +""" + |> typecheck + |> shouldFail + |> withErrorCode 39 +#endif + +module RuntimeAsyncCallbackTests = + open Xunit + open FSharp.Test + open FSharp.Test.Compiler + open System.IO + +#if NETCOREAPP + [] + let ``runtime async supports inlining of a lambda`` () = + FSharp """ +module RuntimeAsyncInlineLambdaTest + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers + +let inline makeFragment () = + fun x -> + AsyncHelpers.Await (Task.Delay 1000) + printfn "Hello from async function with input: %d" x + +let inline consume([] f) = + __runtimeAsyncReturn(f 42) + +[] +let main _ = + consume (makeFragment()) |> _.Result |> ignore + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compileExeAndRun + |> shouldSucceed + + [] + let ``runtime async supports inlining of a multi argument lambda`` () = + FSharp """ +module RuntimeAsyncInlineMultiArgumentLambdaTest + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers + +let inline makeFragment () = + fun x y -> + AsyncHelpers.Await (Task.Delay 1) + x + y + +let inline consume([] f) = + __runtimeAsyncReturn(f 40 2) + +[] +let main _ = + if (consume (makeFragment())).Result <> 42 then 1 else 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compileExeAndRun + |> shouldSucceed + + [] + let ``runtime async fuses suspension in inline returned closures`` () = + FSharp """ +module RuntimeAsyncInlineReturnedClosureTest + +open System +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +type Code = obj -> unit + +type Builder() = + member inline _.Delay([] generator: unit -> Code) : Code = + fun state -> generator() state + + member inline _.Zero() : Code = + fun _ -> () + + member inline _.Yield(_: int) : Code = + fun _ -> () + + member inline _.Bind(task: Task, [] continuation: unit -> Code) : Code = + fun state -> + AsyncHelpers.Await task + continuation() state + + member inline _.Combine(first: Code, [] second: Code) : Code = + fun state -> + first state + second state + + member inline _.Run([] code: Code) : Task = + StateMachineHelpers.__runtimeAsyncReturnUnit (code null) + +[] +let main _ = + let builder = Builder() + builder { + yield 1 + do! Task.Delay(1) + } + |> _.Wait() + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + [] + [] + let ``runtime async fuses returned closures across recursive bindings`` (optimize: bool, nestedRuntimeAsync: bool) = + let loopBody = + if nestedRuntimeAsync then + "__runtimeAsyncReturn (if count = 0 then AsyncHelpers.Await ready else AsyncHelpers.Await (loop (count - 1)))" + else + "if count = 0 then ready else loop (count - 1)" + + FSharp $""" +module RuntimeAsyncRecursiveReturnedClosureTest + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers + +let mutable constructions = 0 +let mutable invocations = 0 + +let inline runBody ([] body: unit -> unit -> int) = + __runtimeAsyncReturn (body () ()) + +let run (ready: Task) = + runBody (fun () -> + constructions <- constructions + 1 + let rec loop count : Task = + {loopBody} + fun () -> + invocations <- invocations + 1 + AsyncHelpers.Await (loop 2)) + +[] +let main _ = + let ready = TaskCompletionSource() + let result = run ready.Task + if result.IsCompleted || constructions <> 1 || invocations <> 1 then + failwith "Callback evaluation or suspension was lost" + ready.SetResult 42 + if result.GetAwaiter().GetResult() <> 42 || constructions <> 1 || invocations <> 1 then + failwith "Recursive callback returned an incorrect result" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + let ``runtime async does not duplicate effectful InlineIfLambda arguments`` (optimize: bool) = + FSharp """ +module RuntimeAsyncInlineIfLambdaEffectsTest + +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers + +let mutable calls = 0 + +let effect () = + calls <- calls + 1 + fun () -> 1 + +let inline plainTwice ([] f: unit -> int) = f () + f () +let inline twice ([] f: unit -> int) = + __runtimeAsyncReturn (f () + f ()) +let inline unused ([] f: unit -> int) = + __runtimeAsyncReturn 20 + +[] +let main _ = + let plainResult = plainTwice (effect ()) + let plainCalls = calls + calls <- 0 + let twiceResult = (twice (effect ())).GetAwaiter().GetResult() + let twiceCalls = calls + calls <- 0 + let unusedResult = (unused (effect ())).GetAwaiter().GetResult() + let unusedCalls = calls + + if plainResult = 2 && plainCalls = 1 + && twiceResult = 2 && twiceCalls = 1 + && unusedResult = 20 && unusedCalls = 1 then + 0 + else + 1 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + let ``runtime async preserves evaluation order for curried inline applications`` (optimize: bool) = + FSharp """ +module RuntimeAsyncCurriedApplicationTest + +open System.Threading.Tasks +open Microsoft.FSharp.Core.CompilerServices let events = ResizeArray() @@ -1070,418 +1424,183 @@ let main _ = else 1 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -let ``runtime async rejects synchronized methods`` () = - FSharp """ -module RuntimeAsyncSynchronizedTest - -open System -open System.Threading.Tasks -open System.Runtime.CompilerServices -open Microsoft.FSharp.Core.CompilerServices + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed -[] -let f () : Task = - StateMachineHelpers.__runtimeAsyncReturn ( - AsyncHelpers.Await(Task.Delay(1).ContinueWith(fun _ -> 1))) -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compile - |> shouldFail - |> withErrorCode 3921 - -[] -let ``runtime async combines awaited chunks without delegates`` () = - FSharp runtimeAsyncRawSource - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compile - |> verifyILContains [ - "Task::Delay(int32)" - "AsyncHelpers::Await(class [runtime]System.Threading.Tasks.Task)" - ] - |> shouldSucceed - -[] -let ``runtime async specializes nested inline suspensions without optimization`` () = - FSharp runtimeAsyncNestedInlineSource - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withNoOptimize - |> compile - |> verifyILContains [ "AsyncHelpers::Await(class [runtime]System.Threading.Tasks.Task`1)" ] - -[] -[] -[] -let ``runtime task builder fixture executes through runtime async`` (optimize: bool) = - FsFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTaskBuilder.fs")) - |> withAdditionalSourceFile ( - SourceFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTasks.fs")) - ) - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -let ``runtime task AsyncLocal values propagate through runtime async`` () = - FsFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTaskBuilder.fs")) - |> withAdditionalSourceFile ( - SourceFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncAsyncLocal.fs")) - ) - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compileExeAndRun - |> shouldSucceed - -[] -let ``runtime async direct intrinsic fixture executes`` () = - Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncBasic.fs") - |> FsFromPath - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async low level async enumerable fixture executes`` (optimize: bool) = - Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncEnumerableLowLevel.fs") - |> FsFromPath - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async enumerable builder fixture executes`` (optimize: bool) = - FsFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTaskBuilder.fs")) - |> withAdditionalSourceFile ( - SourceFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncEnumerable.fs")) - ) - |> withAdditionalSourceFile ( - SourceFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncEnumerableTests.fs")) - ) - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -[] -[] -let ``runtime async delegate await does not repeat a pending sequence move`` (optimize: bool, recipe: bool) = - FSharp $""" -module RuntimeAsyncDelegateAwait + [] + [] + [] + let ``runtime async evaluates conditional callback construction once`` (optimize: bool) = + FSharp """ +module RuntimeAsyncConditionalCallbackConstructionTest -open System -open System.Collections.Generic -open System.Threading open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -type Started<'T> = delegate of unit -> 'T -let mutable constructions = 0 - -type Builder() = - member inline _.Delay([] code: unit -> 'T) = code - member inline _.Run([] code: unit -> 'T) : Task<'T> = - StateMachineHelpers.__runtimeAsyncReturn(code()) - member inline _.Source(work: ValueTask<'T>) = - constructions <- constructions + 1 - Started(fun () -> AsyncHelpers.Await work) - member inline _.Bind([] work: Started<'T>, [] next: 'T -> 'U) = - next (work.Invoke()) - member inline _.While([] guard: unit -> bool, [] body: unit -> unit) = - while guard() do body() - member inline _.Zero() = () - member inline _.Combine(_: unit, [] next: unit -> 'T) = next() - member inline _.Return(value) = value +let events = ResizeArray() +let note text = events.Add text -let builder = Builder() +[] +let choose () = + note "choose" + true -type GuardedEnumerator(gate: Task) = - let mutable active = 0 - let mutable count = 0 - interface IAsyncEnumerator with - member _.Current = count - member _.MoveNextAsync() = - if Interlocked.Exchange(&active, 1) = 1 then - invalidOp "MoveNextAsync cannot be called concurrently." - StateMachineHelpers.__runtimeAsyncReturnValueTask( - try - AsyncHelpers.Await gate - count <- count + 1 - count = 1 - finally - Interlocked.Exchange(&active, 0) |> ignore) - member _.DisposeAsync() = ValueTask() +let inline twice ([] body: unit -> int) = + StateMachineHelpers.__runtimeAsyncReturn (body() + body()) -let collect (e: IAsyncEnumerator) = - builder {{ - let values = ResizeArray() - while! e.MoveNextAsync() do - values.Add(e.Current) - return values.ToArray() - }} +let run (gate: Task) = + twice ( + if choose() then + note "construct" + fun () -> + note "body" + AsyncHelpers.Await gate + else + fun () -> 0) [] let main _ = - let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let enumerator = - if {if recipe then "true" else "false"} then - let source = StateMachineHelpers.__runtimeAsyncSequence(fun () -> seq {{ - do AsyncHelpers.Await gate.Task - yield 1 - }}) - source.GetAsyncEnumerator() - else - GuardedEnumerator(gate.Task) :> IAsyncEnumerator - let pending = collect enumerator - if pending.IsCompleted then - pending.GetAwaiter().GetResult() |> ignore - failwith "Expected a pending first move" - if constructions <> 1 then failwith "Expected one construction before suspension" - gate.SetResult(()) - if pending.GetAwaiter().GetResult() <> [| 1 |] || constructions <> 2 then - failwith "Unexpected sequence values or delegate constructions" - 0 -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async handles imported InlineIfLambda delegate invocations`` (optimize: bool) = - let library = - FSharp """ -module ImportedDelegateAwait -open Microsoft.FSharp.Core.CompilerServices - -type Started<'T> = delegate of unit -> 'T + let gate = TaskCompletionSource() + let work = run gate.Task + gate.SetResult(21) + let result = work.GetAwaiter().GetResult() -let inline run ([] callback: Started) = - StateMachineHelpers.__runtimeAsyncReturn(callback.Invoke()) + if result = 42 && events.ToArray() = [| "choose"; "construct"; "body"; "body" |] then 0 else 1 """ - |> withName "ImportedDelegateAwait" |> withLangVersionPreview |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed - FSharp """ -module RuntimeAsyncImportedDelegate -open System.Threading.Tasks -open System.Runtime.CompilerServices -open ImportedDelegateAwait - -let run (gate: Task) (cleanup: Task) (entered: TaskCompletionSource) = - ImportedDelegateAwait.run (Started(fun () -> - try - AsyncHelpers.Await gate - finally - entered.SetResult(()) - AsyncHelpers.Await cleanup)) + [] + [] + [] + [] + [] + let ``Issue 20577 preserves stateful conditional fold callbacks`` (optimize: bool, useList: bool) = + let source = $""" +module RuntimeAsyncConditionalFoldTest -[] -let main _ = - let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let cleanup = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let entered = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let pending = run gate.Task cleanup.Task entered - if pending.IsCompleted then failwith "Expected suspension" - gate.SetResult 42 - if not (entered.Task.Wait(5000)) || pending.IsCompleted then - failwith "Cleanup must suspend before completing the delegate" - cleanup.SetResult(()) - if pending.GetAwaiter().GetResult() <> 42 then failwith "Lost delegate await" - 0 -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> withReferences [ library ] - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async inlines nested single-use delegate sources`` (optimize: bool) = - let source = - FSharp """ -module NestedDelegateSource +open System.Collections.Generic open System.Threading.Tasks -open System.Runtime.CompilerServices -open Microsoft.FSharp.Core.CompilerServices - -type Started<'T> = delegate of unit -> 'T -let mutable constructions = 0 - -let inline compose ([] source: Started) = - Started(fun () -> source.Invoke() + 1) - -let inline run ([] code: Started) = - StateMachineHelpers.__runtimeAsyncReturn(code.Invoke()) - -[] -let execute (pending: Task) chooseFirst = - run (compose ( - if chooseFirst then - Started(fun () -> AsyncHelpers.Await pending) - else - Started(fun () -> AsyncHelpers.Await pending + 1))) +open RuntimeTaskBuilder.RuntimeTask -let inline runAfterConstruction ([] code: Started) = - StateMachineHelpers.__runtimeAsyncReturn( - if constructions = 0 then failwith "Callback constructed after entering run" - code.Invoke()) +let run deduplicate (ready: Task) = + runtimeTask {{ + let! initial = ready + let folder = + if deduplicate then + let seen = HashSet() + fun total item -> if seen.Add item then total + item else total + else + fun total item -> total + item -[] -let executeWithConstruction (pending: Task) = - runAfterConstruction (compose ( - constructions <- constructions + 1 - Started(fun () -> AsyncHelpers.Await pending))) + return + {if useList then "List.fold folder initial [ 1; 1; 2 ]" else "Array.fold folder initial [| 1; 1; 2 |]"} + }} [] let main _ = - let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let first = execute gate.Task true - let second = execute gate.Task false - let third = executeWithConstruction gate.Task - if first.IsCompleted || second.IsCompleted || third.IsCompleted || constructions <> 1 then - failwith "Delegate construction or suspension changed" - gate.SetResult 41 - if first.GetAwaiter().GetResult() <> 42 || second.GetAwaiter().GetResult() <> 43 || third.GetAwaiter().GetResult() <> 42 then - failwith "Nested delegate result changed" + for deduplicate in [ true; false ] do + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let work = run deduplicate gate.Task + if work.IsCompleted then failwith "Expected suspension" + gate.SetResult 0 + let expected = if deduplicate then 3 else 4 + if work.GetAwaiter().GetResult() <> expected then failwith "Callback state was lost" 0 """ + + FsFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTaskBuilder.fs")) + |> withAdditionalSourceFile (FsSourceWithFileName "RuntimeAsyncConditionalFoldTest.fs" source) |> withLangVersionPreview |> withFSharpCoreShippedNet |> withOptimization optimize |> compileExeAndRun |> shouldSucceed - // The construction in executeWithConstruction precedes its runtime-async body, which therefore starts as - // a closure; callbacks are inlined, so that closure is the only async Invoke. - source |> withMetadataReader (fun md -> - let asyncInvokes = - [ for handle in md.TypeDefinitions do - let ty = md.GetTypeDefinition handle - for methodHandle in ty.GetMethods() do - let method = md.GetMethodDefinition methodHandle - if md.GetString method.Name = "Invoke" && int method.ImplAttributes &&& 0x2000 <> 0 then - yield md.GetString ty.Name ] - let asyncInvoke = Assert.Single asyncInvokes - Assert.StartsWith("executeWithConstruction@", asyncInvoke)) - -[] -[] -[] -let ``runtime async merges delegate sources`` (optimize: bool) = - FSharp """ -module RuntimeAsyncMergedDelegates + [] + [] + [] + let ``runtime async conditional callbacks share mutable captures with closures`` (optimize: bool) = + FSharp """ +module RuntimeAsyncConditionalMutableCapture + open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -type Started<'T> = delegate of unit -> 'T - -type Builder() = - member inline _.Delay([] code: unit -> 'T) = code - member inline _.Source(task: Task<'T>) = Started(fun () -> AsyncHelpers.Await task) - member inline _.MergeSources([] left: Started<'A>, [] right: Started<'B>) = - Started(fun () -> struct (left.Invoke(), right.Invoke())) - member inline _.Bind([] source: Started<'T>, [] next: 'T -> 'U) = - next (source.Invoke()) - member inline _.Return(value: 'T) = value - member inline _.Run([] code: unit -> 'T) : Task<'T> = - StateMachineHelpers.__runtimeAsyncReturn (code()) +let mutable observe = fun () -> -1 -let builder = Builder() +let inline invoke ([] callback: unit -> int) = + StateMachineHelpers.__runtimeAsyncReturn (callback ()) -let run (left: Task) (right: Task) = - builder { - let! x = left - and! y = right - return x + y - } +let run flag = + invoke ( + if flag then + let mutable count = 0 + observe <- fun () -> count + fun () -> + AsyncHelpers.Await(Task.FromResult 0) |> ignore + count <- count + 1 + count + else + fun () -> 0) -let runThree (first: Task) (second: Task) (third: Task) = - builder { - let! x = first - and! y = second - and! z = third - return x + y + z - } +let runMultiple flag = + invoke ( + if flag then + let mutable count = 0 + let mutable additional = 0 + observe <- fun () -> count + additional + fun () -> + AsyncHelpers.Await(Task.FromResult 0) |> ignore + count <- count + 1 + additional <- additional + 1 + count + additional + else + fun () -> 0) [] let main _ = - let left = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let right = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let pending = run left.Task right.Task - if pending.IsCompleted then failwith "Expected a pending result" - left.SetResult 1 - if pending.IsCompleted then failwith "Right source was not awaited" - right.SetResult 2 - if pending.GetAwaiter().GetResult() <> 3 then failwith "Wrong result" - let third = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let nested = runThree left.Task right.Task third.Task - if nested.IsCompleted then failwith "Nested merge completed before the third source" - third.SetResult 3 - if nested.GetAwaiter().GetResult() <> 6 then failwith "Wrong nested result" - 0 + let actual = (run true).Result, observe () + let multiple = (runMultiple true).Result, observe () + if actual = (1, 1) && multiple = (2, 2) then 0 else 1 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async inlines repeated conditional delegate invocations`` (optimize: bool) = - let library = - FSharp """ -module ImportedDelegateCallbacks -open Microsoft.FSharp.Core.CompilerServices + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed -type Started<'T> = delegate of unit -> 'T + [] + [] + [] + let ``runtime async inlines a stateful conditional callback from an imported inline`` (optimize: bool) = + let library = + FSharp """ +module RuntimeAsyncCallbackLibrary -let inline invokeTwice ([] callback: Started) = - callback.Invoke() + callback.Invoke() +let inline invokeTwice ([] callback: unit -> int) = + callback() + callback() """ - |> withName "ImportedDelegateCallbacks" - |> withLangVersionPreview - |> withFSharpCoreShippedNet + |> withName "RuntimeAsyncCallbackLibrary" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + + FSharp """ +module RuntimeAsyncImportedCallbackTest - FSharp """ -module RuntimeAsyncRepeatedDelegate open System.Collections.Generic open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -open ImportedDelegateCallbacks +open RuntimeAsyncCallbackLibrary let mutable constructions = 0 let mutable invocations = 0 @@ -1492,1287 +1611,1192 @@ let run (gate: Task) deduplicate = if deduplicate then constructions <- constructions + 1 let seen = HashSet() - Started(fun () -> + fun () -> invocations <- invocations + 1 let first = seen.Add 1 AsyncHelpers.Await gate - if first then 1 else 0) + if first then 1 else 0 else - Started(fun () -> 2))) + fun () -> 2)) [] let main _ = let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let pending = run gate.Task true - if pending.IsCompleted || constructions <> 1 || invocations <> 1 then - failwith "Callback construction or first invocation changed" + let work = run gate.Task true + if work.IsCompleted || constructions <> 1 || invocations <> 1 then + failwith "Callback construction or first invocation did not run before suspension" gate.SetResult(()) - if pending.GetAwaiter().GetResult() <> 1 || constructions <> 1 || invocations <> 2 then - failwith "Repeated calls did not share callback state" - if (run gate.Task false).GetAwaiter().GetResult() <> 4 || constructions <> 1 then - failwith "Other branch changed" + if work.GetAwaiter().GetResult() <> 1 || constructions <> 1 || invocations <> 2 then + failwith "Callback state was not shared" + if (run gate.Task false).GetAwaiter().GetResult() <> 4 || constructions <> 1 || invocations <> 2 then + failwith "The other branch changed" 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> withReferences [ library ] - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -[] -[] -let ``runtime async does not rewrite delegates passed to opaque consumers`` (optimize: bool, quoted: bool) = - let opaqueUse = - if quoted then - "let saved = <@ callback.Invoke() @> in saved.ToString() |> ignore" - else - "consume callback |> ignore" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> withReferences [ library ] + |> compileExeAndRun + |> shouldSucceed - FSharp $""" -module RuntimeAsyncOpaqueDelegate + [] + [] + [] + let ``runtime async inlined conditional callbacks preserve cleanup after suspension`` (optimize: bool) = + FSharp """ +module RuntimeAsyncConditionalCallbackCleanupTest +open System.Collections.Generic open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -type Started<'T> = delegate of unit -> 'T - -[] -let consume (callback: Started) = callback.Invoke() +let mutable cleaned = 0 -let inline invoke ([] callback: Started) = - StateMachineHelpers.__runtimeAsyncReturn( - let result = callback.Invoke() - {opaqueUse} - result) +let inline invokeTwice ([] callback: unit -> int) = + StateMachineHelpers.__runtimeAsyncReturn (callback() + callback()) -let run (gate: Task) flag = - invoke (if flag then Started(fun () -> AsyncHelpers.Await gate) else Started(fun () -> 0)) -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compile - |> shouldFail - |> withErrorCode 3918 - -[] -[] -[] -[] -[] -let ``runtime async rejects an unmarked delegate method`` (optimize: bool, marked: bool) = - let invocation = "consume (Started(fun () -> AsyncHelpers.Await gate))" - let body = - if marked then - $"StateMachineHelpers.__runtimeAsyncReturn ({invocation})" +let run (gate: Task) (cleanupGate: Task) (cleanupStarted: TaskCompletionSource) = + invokeTwice ( + if true then + let seen = HashSet() + fun () -> + try + let first = seen.Add 1 + AsyncHelpers.Await gate + if first then 1 else 0 + finally + cleanupStarted.TrySetResult(()) |> ignore + AsyncHelpers.Await cleanupGate + cleaned <- cleaned + 1 else - invocation - - FSharp $""" -module RuntimeAsyncUnmarkedDelegate -open System.Threading.Tasks -open System.Runtime.CompilerServices -open Microsoft.FSharp.Core.CompilerServices - -type Started<'T> = delegate of unit -> 'T - -[] -let consume (callback: Started) = callback.Invoke() + fun () -> 0) -let run (gate: Task) = {body} +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let cleanupGate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let cleanupStarted = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let work = run gate.Task cleanupGate.Task cleanupStarted + if work.IsCompleted || cleaned <> 0 then failwith "Cleanup ran before suspension" + gate.SetResult(()) + if not (cleanupStarted.Task.Wait(5000)) || work.IsCompleted then + failwith "Cleanup did not suspend" + cleanupGate.SetResult(()) + if work.GetAwaiter().GetResult() <> 1 || cleaned <> 2 then + failwith "Cleanup or callback state was lost" + 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compile - |> shouldFail - |> withErrorCode 3918 - -[] -let ``runtime async enumerable CE debug points stay at call sites`` () = - let source = - """module RuntimeAsyncEnumerableDebug -open System.Threading.Tasks -open RuntimeAsyncEnumerable - -let first () = - asyncSeq { - do! Task.Delay 1 - yield 1 - } + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed -let second () = - asyncSeq { - do! Task.Delay 1 - yield 2 - } -""" + [] + [] + [] + [] + [] + let ``runtime async does not rewrite opaque callback consumers`` (optimize: bool, quoted: bool) = + let opaqueUse = + if quoted then + "let saved = <@ callback() @> in saved.ToString() |> ignore" + else + "consume callback |> ignore" - FsFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTaskBuilder.fs")) - |> withAdditionalSourceFile ( - SourceFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncEnumerable.fs")) - ) - |> withAdditionalSourceFile (FsSourceWithFileName "RuntimeAsyncEnumerableDebug.fs" source) - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withPortablePdb - |> withNoOptimize - |> compile - |> shouldSucceed - |> verifyPdb [ - VerifyRuntimeAsyncMethodSequencePointsInSource("RuntimeAsyncEnumerableDebug.fs", 6, 8) - VerifyRuntimeAsyncMethodSequencePointsInSource("RuntimeAsyncEnumerableDebug.fs", 12, 14) - ] - -[] -[] -[] -let ``runtime async inlined InlineIfLambda callback keeps its statement debug points`` (optimize: bool) = - let statementPoints = [ Line 13, Col 9, Line 13, Col 45; Line 14, Col 9, Line 14, Col 27; Line 15, Col 9, Line 15, Col 18 ] - let callSitePoint = [ Line 12, Col 5, Line 15, Col 19 ] - - FSharp """ -module CallbackDebugPoints + FSharp $""" +module RuntimeAsyncOpaqueCallbackTest open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -let inline run ([] body: unit -> int) : Task = - StateMachineHelpers.__runtimeAsyncReturn (body ()) - -let work (ready: Task) = - run (fun () -> - let value = AsyncHelpers.Await ready - printfn "%d" value - value + 1) -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withPortablePdb - |> withOptimization optimize - |> compile - |> shouldSucceed - |> verifyPdb [ VerifyMethodSequencePoints("work", (if optimize then statementPoints else callSitePoint @ statementPoints)) ] - -[] -[] -[] -[] -[] -let ``runtime async conditional callback keeps mutable capture in its lexical scope`` (insideContext: bool, asDelegate: bool) = - let callbackType, callbackCall, callbackStart, callbackEnd = - if asDelegate then "Started", "callback.Invoke()", "Started(", ")" - else "unit -> int", "callback ()", "", "" - - let source = $"""module MissingMutableLocal +[] +let consume (callback: unit -> int) = callback() -open System.Threading.Tasks -open System.Runtime.CompilerServices -open Microsoft.FSharp.Core.CompilerServices -type Started<'T> = delegate of unit -> 'T -let inline invoke ([] callback: {callbackType}) : Task = - StateMachineHelpers.__runtimeAsyncReturn ({callbackCall}) +let inline invoke ([] callback: unit -> int) = + StateMachineHelpers.__runtimeAsyncReturn ( + let result = callback() + {opaqueUse} + result) -let run flag (ready: Task) = - let mutable count = 99 - invoke ( - if flag then - printfn "outer = %%d" count - let mutable count = 0 - printfn "%%d" count - {callbackStart}fun () -> - let value = AsyncHelpers.Await ready - count <- count + value - count{callbackEnd} - else - printfn "else = %%d" count - {callbackStart}fun () -> 0{callbackEnd}) -""" - let source = - if insideContext then - source - .Replace(" : Task =", " =") - .Replace($"StateMachineHelpers.__runtimeAsyncReturn ({callbackCall})", callbackCall) - .Replace("let run flag (ready: Task) =", "let run flag (ready: Task) = StateMachineHelpers.__runtimeAsyncReturn (") - .Replace($"fun () -> 0{callbackEnd})", $"fun () -> 0{callbackEnd}))") - else - source - let source = source + """ -[] -let main _ = - let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let pending = run true gate.Task - if pending.IsCompleted then failwith "Callback did not suspend" - gate.SetResult 41 - if pending.GetAwaiter().GetResult() <> 41 then failwith "Mutable capture result changed" - if (run false gate.Task).GetAwaiter().GetResult() <> 0 then failwith "Other branch result changed" - 0 +let run (gate: Task) flag = + invoke (if flag then fun () -> AsyncHelpers.Await gate else fun () -> 0) """ - let result = - FSharp source - |> asExe |> withLangVersionPreview |> withFSharpCoreShippedNet - |> withPortablePdb - |> withNoOptimize - |> compileExeAndRun - |> shouldSucceed + |> withOptimization optimize + |> compile + |> shouldFail + |> withErrorCode 3918 + + [] + let ``runtime async evaluates inline callback construction once`` () = + FSharp """ +module RuntimeAsyncCallbackConstructionTest - use stream = File.OpenRead(Path.ChangeExtension(result.OutputPath.Value, "pdb")) - use provider = MetadataReaderProvider.FromPortablePdbStream stream - let pdb = provider.GetMetadataReader() - - result |> withMetadataReader (fun md -> - let method = - md.MethodDefinitions - |> Seq.filter (fun handle -> md.GetString(md.GetMethodDefinition(handle).Name) = "run") - |> Assert.Single - let localsAt (method: MethodDefinitionHandle) line = - let point = - pdb.GetMethodDebugInformation(method).GetSequencePoints() - |> Seq.filter (fun point -> not point.IsHidden && point.StartLine = line) - |> Assert.Single - [ for handle in pdb.GetLocalScopes method do - let scope = pdb.GetLocalScope handle - if scope.StartOffset <= point.Offset && point.Offset < scope.EndOffset then - for local in scope.GetLocalVariables() do - let variable = pdb.GetLocalVariable local - yield pdb.GetString variable.Name, variable ] - let outerLocals = localsAt method 14 - Assert.DoesNotContain("count (shadowed)", outerLocals |> List.map fst) - let outer = - outerLocals - |> List.filter (fun (name, _) -> name = "count") - |> Assert.Single - |> snd - let inner = - localsAt method 16 - |> List.filter (fun (name, _) -> name = "count") - |> Assert.Single - |> snd - Assert.NotEqual(outer.Index, inner.Index) - Assert.Equal(LocalVariableAttributes.None, inner.Attributes) - let elseLocals = localsAt method 22 - Assert.DoesNotContain("count (shadowed)", elseLocals |> List.map fst) - let restoredOuter = - elseLocals - |> List.filter (fun (name, _) -> name = "count") - |> Assert.Single - |> snd - Assert.Equal(outer.Index, restoredOuter.Index) - let invocationMethod = - md.MethodDefinitions - |> Seq.filter (fun handle -> - pdb.GetMethodDebugInformation(handle).GetSequencePoints() - |> Seq.exists (fun point -> not point.IsHidden && point.StartLine = 19)) - |> Assert.Single - let invoked = - localsAt invocationMethod 19 - |> List.filter (fun (name, _) -> name = "count") - |> Assert.Single - |> snd - Assert.Equal(LocalVariableAttributes.None, invoked.Attributes) - if insideContext then - Assert.Equal(inner.Index, invoked.Index)) - if insideContext then - result |> verifyILNotPresent [ "FSharpRef" ] - -[] -[] -[] -let ``runtime async evaluates a conditional delegate receiver before its argument`` (optimize: bool) = - FSharp """ -module RuntimeAsyncDelegateEvaluationOrder -open System.Collections.Generic open System.Threading.Tasks -open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -type Callback = delegate of int -> int - -let inline invoke ([] callback: Callback) (trace: ResizeArray) = - callback.Invoke(trace.Add "argument"; 3) - -let run (gate: Task) flag (trace: ResizeArray) = - StateMachineHelpers.__runtimeAsyncReturn ( - invoke ( - if flag then - trace.Add "construction" - let mutable count = 10 - Callback(fun x -> - let value = AsyncHelpers.Await gate - count <- count + value + x - count) - else - trace.Add "other" - Callback(fun x -> x * 2)) trace) +let mutable constructed = 0 -[] -let main _ = - let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let trace = ResizeArray() - let pending = run gate.Task true trace - if pending.IsCompleted || trace.ToArray() <> [| "construction"; "argument" |] then - failwith "Receiver or argument evaluation order changed" - gate.SetResult 41 - if pending.GetAwaiter().GetResult() <> 54 then failwith "Mutable capture result changed" - trace.Clear() - if (run gate.Task false trace).GetAwaiter().GetResult() <> 6 - || trace.ToArray() <> [| "other"; "argument" |] then - failwith "Other branch evaluation changed" - 0 +let inline twice ([] f: unit -> int) = + StateMachineHelpers.__runtimeAsyncReturn (f () + f ()) + +let run () = + twice (constructed <- constructed + 1; fun () -> 21) + +[] +let main _ = + let result = run().GetAwaiter().GetResult() + if result = 42 && constructed = 1 then 0 else 1 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -let ``runtime async suspension in exception region executes`` () = - Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTasksAsyncDisposalException.fs") - |> FsFromPath - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compileExeAndRun - |> shouldSucceed - -[] -let ``runtime async reraise preserves the innermost exception after suspension`` () = - FSharp """ -module RuntimeAsyncReraiseTest + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> compileExeAndRun + |> shouldSucceed -open System + [] + [] + [] + let ``runtime async inlines a callback after one that cannot be inlined`` (optimize: bool) = + FSharp """ +module RuntimeAsyncInlineAfterOpaqueCallback open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -let f () : Task = +[] +let consume (f: unit -> int) = f () + +let inline invokeBoth ([] a: unit -> int) ([] b: unit -> int) = + consume a + b() + b() + +let run (gate: Task) flag = StateMachineHelpers.__runtimeAsyncReturn ( - try - raise (InvalidOperationException("outer")) - with _ -> - AsyncHelpers.Await(Task.Delay(1)) - try - raise (ArgumentException("inner")) - with _ -> - reraise ()) + invokeBoth + (let v = AsyncHelpers.Await gate in fun () -> v) + (if flag then (fun () -> AsyncHelpers.Await gate + 1) else (fun () -> 2))) [] let main _ = - try - f().GetAwaiter().GetResult() |> ignore - 1 - with - | :? ArgumentException as ex when ex.Message = "inner" -> 0 - | _ -> 1 + if (run (Task.FromResult 10) true).Result <> 32 then failwith "bad" + if (run (Task.FromResult 10) false).Result <> 14 then failwith "bad2" + 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async preserves an outer reraise after a nested suspension`` (optimize: bool) = - FSharp """ -module RuntimeAsyncNestedReraiseTest + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed -open System + [] + [] + [] + let ``runtime async inlines pattern-bound captures of a branch-selected callback`` (optimize: bool) = + FSharp """ +module RuntimeAsyncPatternCapturedCallback open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -let run (gate: Task) (outer: exn) (trace: ResizeArray) : Task = +let mutable constructions = 0 + +let inline twice ([] f: int -> int) = f 1 + f 2 + +let run (gate: Task) (choice: int option) = StateMachineHelpers.__runtimeAsyncReturn ( - try - raise outer - with _ -> - AsyncHelpers.Await gate - try - reraise () - finally - trace.Add "finally") + twice ( + constructions <- constructions + 1 + match choice with + | Some offset -> + let scaled = offset * 10 + fun x -> AsyncHelpers.Await gate + scaled + x + | None -> fun x -> x)) [] let main _ = - let gate = TaskCompletionSource() - let trace = ResizeArray() - let outer = InvalidOperationException("outer") - let work = run gate.Task outer trace - gate.SetResult(()) - - try - work.GetAwaiter().GetResult() |> ignore - 1 - with - | ex when obj.ReferenceEquals(ex, outer) && trace.ToArray() = [| "finally" |] -> 0 - | _ -> 1 + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let pending = run gate.Task (Some 4) + if pending.IsCompleted then failwith "Expected pending result" + gate.SetResult 100 + if pending.GetAwaiter().GetResult() <> 283 then failwith "bad some" + if (run gate.Task None).Result <> 3 then failwith "bad none" + if constructions <> 2 then failwith "construction repeated" + 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async rejects suspension in an unmarked object member`` (optimize: bool) = - FSharp """ -module RuntimeAsyncUnmarkedMemberTest + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + [] + [] + [] + let ``runtime async reconstructs a branch-selected callback on each loop iteration`` (optimize: bool) = + FSharp """ +module RuntimeAsyncCallbackInLoop open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -type IInt = - abstract Get : unit -> int +let inline invoke ([] f: int -> int) = f 1 + f 2 -let make (gate: Task) = +let run (gate: Task) = StateMachineHelpers.__runtimeAsyncReturn ( - { new IInt with - member _.Get() = AsyncHelpers.Await gate }) + let mutable total = 0 + for i in 0 .. 2 do + total <- + total + + invoke ( + if i % 2 = 0 then + (let k = i * 100 in fun x -> AsyncHelpers.Await gate + k + x) + else + (fun x -> x)) + total) + +[] +let main _ = + let result = (run (Task.FromResult 1000)).Result + if result <> 4409 then failwithf "Unexpected: %d" result + 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compile - |> shouldFail - |> withErrorCode 3918 - -[] -[] -[] -let ``runtime async marker inside an object member remains supported`` (optimize: bool) = - FSharp """ -module RuntimeAsyncMarkedObjectMemberTest + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + [] + [] + [] + let ``runtime async inlines nested stateful branch-selected callbacks`` (optimize: bool) = + FSharp """ +module RuntimeAsyncNestedBranchCallbacks open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -type IInt = - abstract Get : unit -> Task +let inline invoke ([] f: int -> int) = f 1 + f 2 + f 3 -let make (gate: Task) = - { new IInt with - member _.Get() = - StateMachineHelpers.__runtimeAsyncReturn (AsyncHelpers.Await gate) } +let inline pick flag ([] a: int -> int) ([] b: int -> int) = + invoke (if flag then (fun x -> a x + b x) else (fun x -> b (a x))) + +let run (gate: Task) f1 f2 = + StateMachineHelpers.__runtimeAsyncReturn ( + pick + f1 + (if f2 then + let mutable count = 0 + fun x -> + count <- count + 1 + AsyncHelpers.Await gate + x + count + else fun x -> x * 2) + (if f2 then fun x -> x + 1 + else + let mutable count = 0 + fun x -> + count <- count + 1 + AsyncHelpers.Await gate - x + count)) [] let main _ = - let value = (make (Task.FromResult 41)).Get().GetAwaiter().GetResult() - if value = 41 then 0 else 1 + let r a b = (run (Task.FromResult 10) a b).Result + let results = r true true, r true false, r false true, r false false + if results <> (51, 42, 45, 24) then failwithf "Unexpected: %A" results + 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async evaluates conditional callback construction once`` (optimize: bool) = - FSharp """ -module RuntimeAsyncConditionalCallbackConstructionTest + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + [] + [] + let ``runtime async lowers deeply nested callback constructions`` (optimize: bool, asDelegate: bool) = + let depth = 1500 + let statements = String.replicate depth " touch ()\n" + let callbackType, invocation, wrap = + if asDelegate then + "Callback", "callback.Invoke", fun body -> $"Callback(fun x -> {body})" + else + "int -> int", "callback", fun body -> $"(fun x -> {body})" + let source = $""" +module RuntimeAsyncDeepConstruction +open System open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -let events = ResizeArray() -let note text = events.Add text - +type Callback = delegate of int -> int +let mutable constructions = 0 [] -let choose () = - note "choose" - true - -let inline twice ([] body: unit -> int) = - StateMachineHelpers.__runtimeAsyncReturn (body() + body()) +let touch () = constructions <- constructions + 1 +let inline twice ([] callback: {callbackType}) = {invocation} 1 + {invocation} 2 -let run (gate: Task) = - twice ( - if choose() then - note "construct" - fun () -> - note "body" - AsyncHelpers.Await gate - else - fun () -> 0) +[] +let run (gate: Task) flag = + StateMachineHelpers.__runtimeAsyncReturn ( + twice ( +{statements} let offset = 7 + if flag then {wrap "AsyncHelpers.Await gate + offset + x"} + else {wrap "offset + x"})) [] let main _ = - let gate = TaskCompletionSource() - let work = run gate.Task - gate.SetResult(21) - let result = work.GetAwaiter().GetResult() - - if result = 42 && events.ToArray() = [| "choose"; "construct"; "body"; "body" |] then 0 else 1 + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let pending = run gate.Task true + if pending.IsCompleted then failwith "Expected pending result" + if constructions <> {depth} then failwith "Construction was not evaluated once" + gate.SetResult 41 + if pending.GetAwaiter().GetResult() <> 99 then failwith "Suspending branch changed" + if (run gate.Task false).Result <> 17 then failwith "Non-suspending branch changed" + if constructions <> {2 * depth} then failwith "Construction was repeated" + 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -[] -[] -let ``Issue 20577 preserves stateful conditional fold callbacks`` (optimize: bool, useList: bool) = - let source = $""" -module RuntimeAsyncConditionalFoldTest + let result = + FSharp source + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + // In-process compilation can have a larger stack than command-line compilation. + let outputDirectory = result.Output.OutputPath |> Option.get |> Path.GetDirectoryName + let sourcePath = Path.Combine(outputDirectory, "DeepConstruction.fs") + let outputPath = Path.Combine(outputDirectory, "DeepConstruction.exe") + File.WriteAllText(sourcePath, source) + let cliResult = + runFscProcess [ + yield! CompilerAssert.DefaultProjectOptions(Utilities.TargetFramework.FSharpCoreShippedNet).OtherOptions + yield "--target:exe" + yield "--langversion:preview" + yield if optimize then "--optimize+" else "--optimize-" + yield $"-o:\"{outputPath}\"" + yield $"\"{sourcePath}\"" + ] + Assert.True(cliResult.ExitCode = 0, $"{cliResult.StdOut}\n{cliResult.StdErr}") + + [] + [] + [] + [] + [] + [] + let ``runtime async bounds branch-selected callback copies across all invocations`` (invocations: int, nested: bool, withinBudget: bool) = + let calls = + if nested then + [1 .. invocations] |> List.fold (fun arg _ -> $"f ({arg})") "1" + else + List.init invocations (sprintf "f %d") |> String.concat " + " -open System.Collections.Generic -open System.Threading.Tasks -open RuntimeTaskBuilder.RuntimeTask + let terms = List.init 30 (sprintf "x * %d") |> String.concat " + " -let run deduplicate (ready: Task) = - runtimeTask {{ - let! initial = ready - let folder = - if deduplicate then - let seen = HashSet() - fun total item -> if seen.Add item then total + item else total - else - fun total item -> total + item + let compilation = + FSharp $""" +module RuntimeAsyncOversizedCallback +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices - return - {if useList then "List.fold folder initial [ 1; 1; 2 ]" else "Array.fold folder initial [| 1; 1; 2 |]"} - }} +let inline invoke ([] f: int -> int) = + let unrelated = <@ 42 @> + unrelated.ToString() |> ignore + {calls} -[] -let main _ = - for deduplicate in [ true; false ] do - let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let work = run deduplicate gate.Task - if work.IsCompleted then failwith "Expected suspension" - gate.SetResult 0 - let expected = if deduplicate then 3 else 4 - if work.GetAwaiter().GetResult() <> expected then failwith "Callback state was lost" +let run (gate: Task) flag = + StateMachineHelpers.__runtimeAsyncReturn ( + invoke (if flag then (fun x -> AsyncHelpers.Await gate + {terms}) else (fun x -> x))) + +[] +let main _ = + let evaluate x = List.fold (fun total n -> total + x * n) 10 [0 .. 29] + let expected = + if {nested.ToString().ToLowerInvariant()} then + [1 .. {invocations}] |> List.fold (fun arg _ -> evaluate arg) 1 + else + [0 .. {invocations - 1}] |> List.sumBy evaluate + let expectedOtherBranch = if {nested.ToString().ToLowerInvariant()} then 1 else {invocations * (invocations - 1) / 2} + let result flag = (run (Task.FromResult 10) flag).Result + if result true <> expected then failwith "Unexpected suspending branch result" + if result false <> expectedOtherBranch then failwith "Unexpected other branch result" 0 """ + |> asExe + |> withLangVersionPreview + |> withFSharpCoreShippedNet - FsFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTaskBuilder.fs")) - |> withAdditionalSourceFile (FsSourceWithFileName "RuntimeAsyncConditionalFoldTest.fs" source) - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async conditional callbacks share mutable captures with closures`` (optimize: bool) = - FSharp """ -module RuntimeAsyncConditionalMutableCapture + if withinBudget then + compilation |> compileExeAndRun |> shouldSucceed + else + compilation |> compile |> shouldFail |> withErrorCode 3918 + [] + [] + [] + let ``runtime async callback selected by a branch keeps AsyncLocal changes`` (optimize: bool) = + FSharp """ +module RuntimeAsyncBranchCallbackAsyncLocal +open System.Threading open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -let mutable observe = fun () -> -1 - -let inline invoke ([] callback: unit -> int) = - StateMachineHelpers.__runtimeAsyncReturn (callback ()) +let context = AsyncLocal() -let run flag = - invoke ( - if flag then - let mutable count = 0 - observe <- fun () -> count - fun () -> - AsyncHelpers.Await(Task.FromResult 0) |> ignore - count <- count + 1 - count - else - fun () -> 0) +let inline invoke ([] f: unit -> string) = + let seen = f () + seen + "/" + context.Value -let runMultiple flag = - invoke ( - if flag then - let mutable count = 0 - let mutable additional = 0 - observe <- fun () -> count + additional - fun () -> - AsyncHelpers.Await(Task.FromResult 0) |> ignore - count <- count + 1 - additional <- additional + 1 - count + additional - else - fun () -> 0) +let run (gate: Task) flag = + StateMachineHelpers.__runtimeAsyncReturn ( + context.Value <- "outer" + invoke ( + if flag then + (let captured = context.Value + ":" + fun () -> + let before = context.Value + AsyncHelpers.Await gate |> ignore + let after = context.Value + context.Value <- "inner" + captured + before + "-" + after) + else + (fun () -> "none"))) [] let main _ = - let actual = (run true).Result, observe () - let multiple = (runMultiple true).Result, observe () - if actual = (1, 1) && multiple = (2, 2) then 0 else 1 -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async inlines a stateful conditional callback from an imported inline`` (optimize: bool) = - let library = - FSharp """ -module RuntimeAsyncCallbackLibrary - -let inline invokeTwice ([] callback: unit -> int) = - callback() + callback() + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let pending = run gate.Task true + if pending.IsCompleted then failwith "Expected pending result" + gate.SetResult 1 + let result = pending.GetAwaiter().GetResult() + if result <> "outer:outer-outer/inner" then failwithf "Unexpected: %s" result + let completed = (run (Task.FromResult 1) true).Result + if completed <> "outer:outer-outer/inner" then failwithf "Unexpected completed: %s" completed + if (run (Task.FromResult 1) false).Result <> "none/outer" then failwith "bad2" + if not (isNull context.Value) then failwith "AsyncLocal leaked to caller" + 0 """ - |> withName "RuntimeAsyncCallbackLibrary" |> withLangVersionPreview |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed - FSharp """ -module RuntimeAsyncImportedCallbackTest - -open System.Collections.Generic + [] + [] + [] + let ``runtime async prepares a body after a callback is inlined into its handler`` (optimize: bool) = + FSharp """ +module RuntimeAsyncCallbackInHandler open System.Threading.Tasks -open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -open RuntimeAsyncCallbackLibrary -let mutable constructions = 0 -let mutable invocations = 0 +type Started<'T> = delegate of unit -> 'T -let run (gate: Task) deduplicate = +let inline run ([] code: Started) = StateMachineHelpers.__runtimeAsyncReturn ( - invokeTwice ( - if deduplicate then - constructions <- constructions + 1 - let seen = HashSet() - fun () -> - invocations <- invocations + 1 - let first = seen.Add 1 - AsyncHelpers.Await gate - if first then 1 else 0 - else - fun () -> 2)) + try + failwith "boom" + with _ -> + code.Invoke()) + +let execute (pending: Task) chooseFirst = + run ( + if chooseFirst then + Started(fun () -> System.Runtime.CompilerServices.AsyncHelpers.Await pending) + else + Started(fun () -> System.Runtime.CompilerServices.AsyncHelpers.Await pending + 1)) [] let main _ = - let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let work = run gate.Task true - if work.IsCompleted || constructions <> 1 || invocations <> 1 then - failwith "Callback construction or first invocation did not run before suspension" - gate.SetResult(()) - if work.GetAwaiter().GetResult() <> 1 || constructions <> 1 || invocations <> 2 then - failwith "Callback state was not shared" - if (run gate.Task false).GetAwaiter().GetResult() <> 4 || constructions <> 1 || invocations <> 2 then - failwith "The other branch changed" + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let first = execute gate.Task true + let second = execute gate.Task false + if first.IsCompleted || second.IsCompleted then failwith "Expected pending results" + gate.SetResult 41 + if first.GetAwaiter().GetResult() <> 41 || second.GetAwaiter().GetResult() <> 42 then failwith "Wrong result" 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> withReferences [ library ] - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async inlined conditional callbacks preserve cleanup after suspension`` (optimize: bool) = - FSharp """ -module RuntimeAsyncConditionalCallbackCleanupTest + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed +#endif + +module RuntimeAsyncDelegateTests = + open Xunit + open FSharp.Test.Compiler + +#if NETCOREAPP + [] + [] + [] + [] + [] + let ``runtime async delegate await does not repeat a pending sequence move`` (optimize: bool, recipe: bool) = + FSharp $""" +module RuntimeAsyncDelegateAwait +open System open System.Collections.Generic +open System.Threading open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -let mutable cleaned = 0 +type Started<'T> = delegate of unit -> 'T +let mutable constructions = 0 -let inline invokeTwice ([] callback: unit -> int) = - StateMachineHelpers.__runtimeAsyncReturn (callback() + callback()) +type Builder() = + member inline _.Delay([] code: unit -> 'T) = code + member inline _.Run([] code: unit -> 'T) : Task<'T> = + StateMachineHelpers.__runtimeAsyncReturn(code()) + member inline _.Source(work: ValueTask<'T>) = + constructions <- constructions + 1 + Started(fun () -> AsyncHelpers.Await work) + member inline _.Bind([] work: Started<'T>, [] next: 'T -> 'U) = + next (work.Invoke()) + member inline _.While([] guard: unit -> bool, [] body: unit -> unit) = + while guard() do body() + member inline _.Zero() = () + member inline _.Combine(_: unit, [] next: unit -> 'T) = next() + member inline _.Return(value) = value -let run (gate: Task) (cleanupGate: Task) (cleanupStarted: TaskCompletionSource) = - invokeTwice ( - if true then - let seen = HashSet() - fun () -> +let builder = Builder() + +type GuardedEnumerator(gate: Task) = + let mutable active = 0 + let mutable count = 0 + interface IAsyncEnumerator with + member _.Current = count + member _.MoveNextAsync() = + if Interlocked.Exchange(&active, 1) = 1 then + invalidOp "MoveNextAsync cannot be called concurrently." + StateMachineHelpers.__runtimeAsyncReturnValueTask( try - let first = seen.Add 1 AsyncHelpers.Await gate - if first then 1 else 0 + count <- count + 1 + count = 1 finally - cleanupStarted.TrySetResult(()) |> ignore - AsyncHelpers.Await cleanupGate - cleaned <- cleaned + 1 - else - fun () -> 0) + Interlocked.Exchange(&active, 0) |> ignore) + member _.DisposeAsync() = ValueTask() + +let collect (e: IAsyncEnumerator) = + builder {{ + let values = ResizeArray() + while! e.MoveNextAsync() do + values.Add(e.Current) + return values.ToArray() + }} [] let main _ = let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let cleanupGate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let cleanupStarted = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let work = run gate.Task cleanupGate.Task cleanupStarted - if work.IsCompleted || cleaned <> 0 then failwith "Cleanup ran before suspension" + let enumerator = + if {if recipe then "true" else "false"} then + let source = StateMachineHelpers.__runtimeAsyncSequence(fun () -> seq {{ + do AsyncHelpers.Await gate.Task + yield 1 + }}) + source.GetAsyncEnumerator() + else + GuardedEnumerator(gate.Task) :> IAsyncEnumerator + let pending = collect enumerator + if pending.IsCompleted then + pending.GetAwaiter().GetResult() |> ignore + failwith "Expected a pending first move" + if constructions <> 1 then failwith "Expected one construction before suspension" gate.SetResult(()) - if not (cleanupStarted.Task.Wait(5000)) || work.IsCompleted then - failwith "Cleanup did not suspend" - cleanupGate.SetResult(()) - if work.GetAwaiter().GetResult() <> 1 || cleaned <> 2 then - failwith "Cleanup or callback state was lost" + if pending.GetAwaiter().GetResult() <> [| 1 |] || constructions <> 2 then + failwith "Unexpected sequence values or delegate constructions" 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -[] -[] -let ``runtime async does not rewrite opaque callback consumers`` (optimize: bool, quoted: bool) = - let opaqueUse = - if quoted then - "let saved = <@ callback() @> in saved.ToString() |> ignore" - else - "consume callback |> ignore" - - FSharp $""" -module RuntimeAsyncOpaqueCallbackTest + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed -open System.Threading.Tasks -open System.Runtime.CompilerServices + [] + [] + [] + let ``runtime async handles imported InlineIfLambda delegate invocations`` (optimize: bool) = + let library = + FSharp """ +module ImportedDelegateAwait open Microsoft.FSharp.Core.CompilerServices -[] -let consume (callback: unit -> int) = callback() - -let inline invoke ([] callback: unit -> int) = - StateMachineHelpers.__runtimeAsyncReturn ( - let result = callback() - {opaqueUse} - result) +type Started<'T> = delegate of unit -> 'T -let run (gate: Task) flag = - invoke (if flag then fun () -> AsyncHelpers.Await gate else fun () -> 0) +let inline run ([] callback: Started) = + StateMachineHelpers.__runtimeAsyncReturn(callback.Invoke()) """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compile - |> shouldFail - |> withErrorCode 3918 - -[] -[] -[] -let ``runtime async evaluates a suspending exception filter once`` (optimize: bool) = - FSharp """ -module RuntimeAsyncExceptionFilterTest + |> withName "ImportedDelegateAwait" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + FSharp """ +module RuntimeAsyncImportedDelegate open System.Threading.Tasks open System.Runtime.CompilerServices -open Microsoft.FSharp.Core.CompilerServices +open ImportedDelegateAwait -let run (gate: Task) (predicate: unit -> bool) : Task = - StateMachineHelpers.__runtimeAsyncReturn ( +let run (gate: Task) (cleanup: Task) (entered: TaskCompletionSource) = + ImportedDelegateAwait.run (Started(fun () -> try - failwith "body" - with _ when (AsyncHelpers.Await gate; predicate()) -> - 7) + AsyncHelpers.Await gate + finally + entered.SetResult(()) + AsyncHelpers.Await cleanup)) [] let main _ = - let gate = TaskCompletionSource() - let mutable calls = 0 - - let firstTrue () = - calls <- calls + 1 - calls = 1 - - let work = run gate.Task firstTrue - gate.SetResult(()) - - try - if work.GetAwaiter().GetResult() = 7 && calls = 1 then 0 else 1 - with _ -> - 1 + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let cleanup = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let entered = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let pending = run gate.Task cleanup.Task entered + if pending.IsCompleted then failwith "Expected suspension" + gate.SetResult 42 + if not (entered.Task.Wait(5000)) || pending.IsCompleted then + failwith "Cleanup must suspend before completing the delegate" + cleanup.SetResult(()) + if pending.GetAwaiter().GetResult() <> 42 then failwith "Lost delegate await" + 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -let ``runtime async evaluates inline callback construction once`` () = - FSharp """ -module RuntimeAsyncCallbackConstructionTest + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> withReferences [ library ] + |> compileExeAndRun + |> shouldSucceed + [] + [] + [] + let ``runtime async inlines nested single-use delegate sources`` (optimize: bool) = + let source = + FSharp """ +module NestedDelegateSource open System.Threading.Tasks +open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -let mutable constructed = 0 +type Started<'T> = delegate of unit -> 'T +let mutable constructions = 0 -let inline twice ([] f: unit -> int) = - StateMachineHelpers.__runtimeAsyncReturn (f () + f ()) +let inline compose ([] source: Started) = + Started(fun () -> source.Invoke() + 1) -let run () = - twice (constructed <- constructed + 1; fun () -> 21) +let inline run ([] code: Started) = + StateMachineHelpers.__runtimeAsyncReturn(code.Invoke()) -[] -let main _ = - let result = run().GetAwaiter().GetResult() - if result = 42 && constructed = 1 then 0 else 1 -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compileExeAndRun - |> shouldSucceed +[] +let execute (pending: Task) chooseFirst = + run (compose ( + if chooseFirst then + Started(fun () -> AsyncHelpers.Await pending) + else + Started(fun () -> AsyncHelpers.Await pending + 1))) -[] -let ``runtime async is gated without preview when optimization is disabled`` () = - FSharp """ -module RuntimeAsyncNoOptimizePreviewTest +let inline runAfterConstruction ([] code: Started) = + StateMachineHelpers.__runtimeAsyncReturn( + if constructions = 0 then failwith "Callback constructed after entering run" + code.Invoke()) -open System.Threading.Tasks -open Microsoft.FSharp.Core.CompilerServices +[] +let executeWithConstruction (pending: Task) = + runAfterConstruction (compose ( + constructions <- constructions + 1 + Started(fun () -> AsyncHelpers.Await pending))) -let f (x: int) : Task = - x |> StateMachineHelpers.__runtimeAsyncReturn +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let first = execute gate.Task true + let second = execute gate.Task false + let third = executeWithConstruction gate.Task + if first.IsCompleted || second.IsCompleted || third.IsCompleted || constructions <> 1 then + failwith "Delegate construction or suspension changed" + gate.SetResult 41 + if first.GetAwaiter().GetResult() <> 42 || second.GetAwaiter().GetResult() <> 43 || third.GetAwaiter().GetResult() <> 42 then + failwith "Nested delegate result changed" + 0 """ - |> withLangVersion90 - |> withFSharpCoreShippedNet - |> withNoOptimize - |> compile - |> shouldFail - |> withErrorCode 3350 - -[] -let ``runtime async rejects suspension inside an ordinary sequence`` () = - FSharp """ -module RuntimeAsyncNestedSequenceTest - + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + // The construction in executeWithConstruction precedes its runtime-async body, which therefore starts as + // a closure; callbacks are inlined, so that closure is the only async Invoke. + source |> withMetadataReader (fun md -> + let asyncInvokes = + [ for handle in md.TypeDefinitions do + let ty = md.GetTypeDefinition handle + for methodHandle in ty.GetMethods() do + let method = md.GetMethodDefinition methodHandle + if md.GetString method.Name = "Invoke" && int method.ImplAttributes &&& 0x2000 <> 0 then + yield md.GetString ty.Name ] + let asyncInvoke = Assert.Single asyncInvokes + Assert.StartsWith("executeWithConstruction@", asyncInvoke)) + + [] + [] + [] + let ``runtime async merges delegate sources`` (optimize: bool) = + FSharp """ +module RuntimeAsyncMergedDelegates open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -let f (gate: Task) : Task = - StateMachineHelpers.__runtimeAsyncReturn ( - seq { - let value = AsyncHelpers.Await gate - yield value - yield value + 1 - }) -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> compile - |> shouldFail - |> withErrorCode 3918 - -[] -[] -[] -let ``runtime async inlines a callback after one that cannot be inlined`` (optimize: bool) = - FSharp """ -module RuntimeAsyncInlineAfterOpaqueCallback -open System.Threading.Tasks -open System.Runtime.CompilerServices -open Microsoft.FSharp.Core.CompilerServices +type Started<'T> = delegate of unit -> 'T -[] -let consume (f: unit -> int) = f () +type Builder() = + member inline _.Delay([] code: unit -> 'T) = code + member inline _.Source(task: Task<'T>) = Started(fun () -> AsyncHelpers.Await task) + member inline _.MergeSources([] left: Started<'A>, [] right: Started<'B>) = + Started(fun () -> struct (left.Invoke(), right.Invoke())) + member inline _.Bind([] source: Started<'T>, [] next: 'T -> 'U) = + next (source.Invoke()) + member inline _.Return(value: 'T) = value + member inline _.Run([] code: unit -> 'T) : Task<'T> = + StateMachineHelpers.__runtimeAsyncReturn (code()) -let inline invokeBoth ([] a: unit -> int) ([] b: unit -> int) = - consume a + b() + b() +let builder = Builder() -let run (gate: Task) flag = - StateMachineHelpers.__runtimeAsyncReturn ( - invokeBoth - (let v = AsyncHelpers.Await gate in fun () -> v) - (if flag then (fun () -> AsyncHelpers.Await gate + 1) else (fun () -> 2))) +let run (left: Task) (right: Task) = + builder { + let! x = left + and! y = right + return x + y + } + +let runThree (first: Task) (second: Task) (third: Task) = + builder { + let! x = first + and! y = second + and! z = third + return x + y + z + } [] let main _ = - if (run (Task.FromResult 10) true).Result <> 32 then failwith "bad" - if (run (Task.FromResult 10) false).Result <> 14 then failwith "bad2" + let left = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let right = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let pending = run left.Task right.Task + if pending.IsCompleted then failwith "Expected a pending result" + left.SetResult 1 + if pending.IsCompleted then failwith "Right source was not awaited" + right.SetResult 2 + if pending.GetAwaiter().GetResult() <> 3 then failwith "Wrong result" + let third = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let nested = runThree left.Task right.Task third.Task + if nested.IsCompleted then failwith "Nested merge completed before the third source" + third.SetResult 3 + if nested.GetAwaiter().GetResult() <> 6 then failwith "Wrong nested result" 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async inlines pattern-bound captures of a branch-selected callback`` (optimize: bool) = - FSharp """ -module RuntimeAsyncPatternCapturedCallback + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + let ``runtime async inlines repeated conditional delegate invocations`` (optimize: bool) = + let library = + FSharp """ +module ImportedDelegateCallbacks +open Microsoft.FSharp.Core.CompilerServices + +type Started<'T> = delegate of unit -> 'T + +let inline invokeTwice ([] callback: Started) = + callback.Invoke() + callback.Invoke() +""" + |> withName "ImportedDelegateCallbacks" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + + FSharp """ +module RuntimeAsyncRepeatedDelegate +open System.Collections.Generic open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices +open ImportedDelegateCallbacks let mutable constructions = 0 +let mutable invocations = 0 -let inline twice ([] f: int -> int) = f 1 + f 2 - -let run (gate: Task) (choice: int option) = +let run (gate: Task) deduplicate = StateMachineHelpers.__runtimeAsyncReturn ( - twice ( - constructions <- constructions + 1 - match choice with - | Some offset -> - let scaled = offset * 10 - fun x -> AsyncHelpers.Await gate + scaled + x - | None -> fun x -> x)) + invokeTwice ( + if deduplicate then + constructions <- constructions + 1 + let seen = HashSet() + Started(fun () -> + invocations <- invocations + 1 + let first = seen.Add 1 + AsyncHelpers.Await gate + if first then 1 else 0) + else + Started(fun () -> 2))) [] let main _ = - let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let pending = run gate.Task (Some 4) - if pending.IsCompleted then failwith "Expected pending result" - gate.SetResult 100 - if pending.GetAwaiter().GetResult() <> 283 then failwith "bad some" - if (run gate.Task None).Result <> 3 then failwith "bad none" - if constructions <> 2 then failwith "construction repeated" + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let pending = run gate.Task true + if pending.IsCompleted || constructions <> 1 || invocations <> 1 then + failwith "Callback construction or first invocation changed" + gate.SetResult(()) + if pending.GetAwaiter().GetResult() <> 1 || constructions <> 1 || invocations <> 2 then + failwith "Repeated calls did not share callback state" + if (run gate.Task false).GetAwaiter().GetResult() <> 4 || constructions <> 1 then + failwith "Other branch changed" 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async reconstructs a branch-selected callback on each loop iteration`` (optimize: bool) = - FSharp """ -module RuntimeAsyncCallbackInLoop + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> withReferences [ library ] + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + [] + [] + let ``runtime async does not rewrite delegates passed to opaque consumers`` (optimize: bool, quoted: bool) = + let opaqueUse = + if quoted then + "let saved = <@ callback.Invoke() @> in saved.ToString() |> ignore" + else + "consume callback |> ignore" + + FSharp $""" +module RuntimeAsyncOpaqueDelegate + open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -let inline invoke ([] f: int -> int) = f 1 + f 2 +type Started<'T> = delegate of unit -> 'T -let run (gate: Task) = - StateMachineHelpers.__runtimeAsyncReturn ( - let mutable total = 0 - for i in 0 .. 2 do - total <- - total - + invoke ( - if i % 2 = 0 then - (let k = i * 100 in fun x -> AsyncHelpers.Await gate + k + x) - else - (fun x -> x)) - total) +[] +let consume (callback: Started) = callback.Invoke() + +let inline invoke ([] callback: Started) = + StateMachineHelpers.__runtimeAsyncReturn( + let result = callback.Invoke() + {opaqueUse} + result) -[] -let main _ = - let result = (run (Task.FromResult 1000)).Result - if result <> 4409 then failwithf "Unexpected: %d" result - 0 +let run (gate: Task) flag = + invoke (if flag then Started(fun () -> AsyncHelpers.Await gate) else Started(fun () -> 0)) """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async inlines nested stateful branch-selected callbacks`` (optimize: bool) = - FSharp """ -module RuntimeAsyncNestedBranchCallbacks + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compile + |> shouldFail + |> withErrorCode 3918 + + [] + [] + [] + [] + [] + let ``runtime async rejects an unmarked delegate method`` (optimize: bool, marked: bool) = + let invocation = "consume (Started(fun () -> AsyncHelpers.Await gate))" + let body = + if marked then + $"StateMachineHelpers.__runtimeAsyncReturn ({invocation})" + else + invocation + + FSharp $""" +module RuntimeAsyncUnmarkedDelegate open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -let inline invoke ([] f: int -> int) = f 1 + f 2 + f 3 - -let inline pick flag ([] a: int -> int) ([] b: int -> int) = - invoke (if flag then (fun x -> a x + b x) else (fun x -> b (a x))) +type Started<'T> = delegate of unit -> 'T -let run (gate: Task) f1 f2 = - StateMachineHelpers.__runtimeAsyncReturn ( - pick - f1 - (if f2 then - let mutable count = 0 - fun x -> - count <- count + 1 - AsyncHelpers.Await gate + x + count - else fun x -> x * 2) - (if f2 then fun x -> x + 1 - else - let mutable count = 0 - fun x -> - count <- count + 1 - AsyncHelpers.Await gate - x + count)) +[] +let consume (callback: Started) = callback.Invoke() -[] -let main _ = - let r a b = (run (Task.FromResult 10) a b).Result - let results = r true true, r true false, r false true, r false false - if results <> (51, 42, 45, 24) then failwithf "Unexpected: %A" results - 0 +let run (gate: Task) = {body} """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -[] -[] -let ``runtime async lowers deeply nested callback constructions`` (optimize: bool, asDelegate: bool) = - let depth = 1500 - let statements = String.replicate depth " touch ()\n" - let callbackType, invocation, wrap = - if asDelegate then - "Callback", "callback.Invoke", fun body -> $"Callback(fun x -> {body})" - else - "int -> int", "callback", fun body -> $"(fun x -> {body})" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compile + |> shouldFail + |> withErrorCode 3918 - let source = $""" -module RuntimeAsyncDeepConstruction -open System + [] + [] + [] + let ``runtime async evaluates a conditional delegate receiver before its argument`` (optimize: bool) = + FSharp """ +module RuntimeAsyncDelegateEvaluationOrder +open System.Collections.Generic open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices type Callback = delegate of int -> int -let mutable constructions = 0 -[] -let touch () = constructions <- constructions + 1 -let inline twice ([] callback: {callbackType}) = {invocation} 1 + {invocation} 2 -[] -let run (gate: Task) flag = +let inline invoke ([] callback: Callback) (trace: ResizeArray) = + callback.Invoke(trace.Add "argument"; 3) + +let run (gate: Task) flag (trace: ResizeArray) = StateMachineHelpers.__runtimeAsyncReturn ( - twice ( -{statements} let offset = 7 - if flag then {wrap "AsyncHelpers.Await gate + offset + x"} - else {wrap "offset + x"})) + invoke ( + if flag then + trace.Add "construction" + let mutable count = 10 + Callback(fun x -> + let value = AsyncHelpers.Await gate + count <- count + value + x + count) + else + trace.Add "other" + Callback(fun x -> x * 2)) trace) [] let main _ = let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let pending = run gate.Task true - if pending.IsCompleted then failwith "Expected pending result" - if constructions <> {depth} then failwith "Construction was not evaluated once" + let trace = ResizeArray() + let pending = run gate.Task true trace + if pending.IsCompleted || trace.ToArray() <> [| "construction"; "argument" |] then + failwith "Receiver or argument evaluation order changed" gate.SetResult 41 - if pending.GetAwaiter().GetResult() <> 99 then failwith "Suspending branch changed" - if (run gate.Task false).Result <> 17 then failwith "Non-suspending branch changed" - if constructions <> {2 * depth} then failwith "Construction was repeated" + if pending.GetAwaiter().GetResult() <> 54 then failwith "Mutable capture result changed" + trace.Clear() + if (run gate.Task false trace).GetAwaiter().GetResult() <> 6 + || trace.ToArray() <> [| "other"; "argument" |] then + failwith "Other branch evaluation changed" 0 """ - let result = - FSharp source |> withLangVersionPreview |> withFSharpCoreShippedNet |> withOptimization optimize |> compileExeAndRun |> shouldSucceed +#endif - // In-process compilation can have a larger stack than command-line compilation. - let outputDirectory = result.Output.OutputPath |> Option.get |> Path.GetDirectoryName - let sourcePath = Path.Combine(outputDirectory, "DeepConstruction.fs") - let outputPath = Path.Combine(outputDirectory, "DeepConstruction.exe") - File.WriteAllText(sourcePath, source) - let cliResult = - runFscProcess [ - yield! CompilerAssert.DefaultProjectOptions(Utilities.TargetFramework.FSharpCoreShippedNet).OtherOptions - yield "--target:exe" - yield "--langversion:preview" - yield if optimize then "--optimize+" else "--optimize-" - yield $"-o:\"{outputPath}\"" - yield $"\"{sourcePath}\"" - ] - Assert.True(cliResult.ExitCode = 0, $"{cliResult.StdOut}\n{cliResult.StdErr}") - -[] -[] -[] -[] -[] -[] -let ``runtime async bounds branch-selected callback copies across all invocations`` (invocations: int, nested: bool, withinBudget: bool) = - let calls = - if nested then - [1 .. invocations] |> List.fold (fun arg _ -> $"f ({arg})") "1" - else - List.init invocations (sprintf "f %d") |> String.concat " + " - - let terms = List.init 30 (sprintf "x * %d") |> String.concat " + " +module RuntimeAsyncDebugTests = + open Xunit + open FSharp.Test + open FSharp.Test.Compiler + open System.IO + open System.Reflection.Metadata - let compilation = - FSharp $""" -module RuntimeAsyncOversizedCallback +#if NETCOREAPP + [] + let ``runtime async enumerable CE debug points stay at call sites`` () = + let source = + """module RuntimeAsyncEnumerableDebug open System.Threading.Tasks -open System.Runtime.CompilerServices -open Microsoft.FSharp.Core.CompilerServices - -let inline invoke ([] f: int -> int) = - let unrelated = <@ 42 @> - unrelated.ToString() |> ignore - {calls} +open RuntimeAsyncEnumerable -let run (gate: Task) flag = - StateMachineHelpers.__runtimeAsyncReturn ( - invoke (if flag then (fun x -> AsyncHelpers.Await gate + {terms}) else (fun x -> x))) +let first () = + asyncSeq { + do! Task.Delay 1 + yield 1 + } -[] -let main _ = - let evaluate x = List.fold (fun total n -> total + x * n) 10 [0 .. 29] - let expected = - if {nested.ToString().ToLowerInvariant()} then - [1 .. {invocations}] |> List.fold (fun arg _ -> evaluate arg) 1 - else - [0 .. {invocations - 1}] |> List.sumBy evaluate - let expectedOtherBranch = if {nested.ToString().ToLowerInvariant()} then 1 else {invocations * (invocations - 1) / 2} - let result flag = (run (Task.FromResult 10) flag).Result - if result true <> expected then failwith "Unexpected suspending branch result" - if result false <> expectedOtherBranch then failwith "Unexpected other branch result" - 0 +let second () = + asyncSeq { + do! Task.Delay 1 + yield 2 + } """ - |> asExe + + FsFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTaskBuilder.fs")) + |> withAdditionalSourceFile ( + SourceFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeAsyncEnumerable.fs")) + ) + |> withAdditionalSourceFile (FsSourceWithFileName "RuntimeAsyncEnumerableDebug.fs" source) |> withLangVersionPreview |> withFSharpCoreShippedNet + |> withPortablePdb + |> withNoOptimize + |> compile + |> shouldSucceed + |> verifyPdb [ + VerifyRuntimeAsyncMethodSequencePointsInSource("RuntimeAsyncEnumerableDebug.fs", 6, 8) + VerifyRuntimeAsyncMethodSequencePointsInSource("RuntimeAsyncEnumerableDebug.fs", 12, 14) + ] - if withinBudget then - compilation |> compileExeAndRun |> shouldSucceed - else - compilation |> compile |> shouldFail |> withErrorCode 3918 + [] + [] + [] + let ``runtime async inlined InlineIfLambda callback keeps its statement debug points`` (optimize: bool) = + let statementPoints = [ Line 13, Col 9, Line 13, Col 45; Line 14, Col 9, Line 14, Col 27; Line 15, Col 9, Line 15, Col 18 ] + let callSitePoint = [ Line 12, Col 5, Line 15, Col 19 ] + + FSharp """ +module CallbackDebugPoints -[] -[] -[] -let ``runtime async callback selected by a branch keeps AsyncLocal changes`` (optimize: bool) = - FSharp """ -module RuntimeAsyncBranchCallbackAsyncLocal -open System.Threading open System.Threading.Tasks open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices -let context = AsyncLocal() +let inline run ([] body: unit -> int) : Task = + StateMachineHelpers.__runtimeAsyncReturn (body ()) -let inline invoke ([] f: unit -> string) = - let seen = f () - seen + "/" + context.Value +let work (ready: Task) = + run (fun () -> + let value = AsyncHelpers.Await ready + printfn "%d" value + value + 1) +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withPortablePdb + |> withOptimization optimize + |> compile + |> shouldSucceed + |> verifyPdb [ VerifyMethodSequencePoints("work", (if optimize then statementPoints else callSitePoint @ statementPoints)) ] -let run (gate: Task) flag = - StateMachineHelpers.__runtimeAsyncReturn ( - context.Value <- "outer" - invoke ( - if flag then - (let captured = context.Value + ":" - fun () -> - let before = context.Value - AsyncHelpers.Await gate |> ignore - let after = context.Value - context.Value <- "inner" - captured + before + "-" + after) - else - (fun () -> "none"))) + [] + [] + [] + [] + [] + let ``runtime async conditional callback keeps mutable capture in its lexical scope`` (insideContext: bool, asDelegate: bool) = + let callbackType, callbackCall, callbackStart, callbackEnd = + if asDelegate then "Started", "callback.Invoke()", "Started(", ")" + else "unit -> int", "callback ()", "", "" + + let source = $"""module MissingMutableLocal -[] -let main _ = - let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let pending = run gate.Task true - if pending.IsCompleted then failwith "Expected pending result" - gate.SetResult 1 - let result = pending.GetAwaiter().GetResult() - if result <> "outer:outer-outer/inner" then failwithf "Unexpected: %s" result - let completed = (run (Task.FromResult 1) true).Result - if completed <> "outer:outer-outer/inner" then failwithf "Unexpected completed: %s" completed - if (run (Task.FromResult 1) false).Result <> "none/outer" then failwith "bad2" - if not (isNull context.Value) then failwith "AsyncLocal leaked to caller" - 0 -""" - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -[] -[] -[] -let ``runtime async prepares a body after a callback is inlined into its handler`` (optimize: bool) = - FSharp """ -module RuntimeAsyncCallbackInHandler open System.Threading.Tasks +open System.Runtime.CompilerServices open Microsoft.FSharp.Core.CompilerServices - type Started<'T> = delegate of unit -> 'T +let inline invoke ([] callback: {callbackType}) : Task = + StateMachineHelpers.__runtimeAsyncReturn ({callbackCall}) -let inline run ([] code: Started) = - StateMachineHelpers.__runtimeAsyncReturn ( - try - failwith "boom" - with _ -> - code.Invoke()) - -let execute (pending: Task) chooseFirst = - run ( - if chooseFirst then - Started(fun () -> System.Runtime.CompilerServices.AsyncHelpers.Await pending) +let run flag (ready: Task) = + let mutable count = 99 + invoke ( + if flag then + printfn "outer = %%d" count + let mutable count = 0 + printfn "%%d" count + {callbackStart}fun () -> + let value = AsyncHelpers.Await ready + count <- count + value + count{callbackEnd} else - Started(fun () -> System.Runtime.CompilerServices.AsyncHelpers.Await pending + 1)) - + printfn "else = %%d" count + {callbackStart}fun () -> 0{callbackEnd}) +""" + let source = + if insideContext then + source + .Replace(" : Task =", " =") + .Replace($"StateMachineHelpers.__runtimeAsyncReturn ({callbackCall})", callbackCall) + .Replace("let run flag (ready: Task) =", "let run flag (ready: Task) = StateMachineHelpers.__runtimeAsyncReturn (") + .Replace($"fun () -> 0{callbackEnd})", $"fun () -> 0{callbackEnd}))") + else + source + let source = source + """ [] let main _ = let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) - let first = execute gate.Task true - let second = execute gate.Task false - if first.IsCompleted || second.IsCompleted then failwith "Expected pending results" + let pending = run true gate.Task + if pending.IsCompleted then failwith "Callback did not suspend" gate.SetResult 41 - if first.GetAwaiter().GetResult() <> 41 || second.GetAwaiter().GetResult() <> 42 then failwith "Wrong result" + if pending.GetAwaiter().GetResult() <> 41 then failwith "Mutable capture result changed" + if (run false gate.Task).GetAwaiter().GetResult() <> 0 then failwith "Other branch result changed" 0 """ - |> withLangVersionPreview - |> withFSharpCoreShippedNet - |> withOptimization optimize - |> compileExeAndRun - |> shouldSucceed - -#else -[] -let ``runtime async intrinsic is only available in the shipped net FSharp.Core`` () = - FSharp """ -open System.Threading.Tasks -open Microsoft.FSharp.Core.CompilerServices - -let f : Task = - StateMachineHelpers.__runtimeAsyncReturn 1 -""" - |> typecheck - |> shouldFail - |> withErrorCode 39 + let result = + FSharp source + |> asExe + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withPortablePdb + |> withNoOptimize + |> compileExeAndRun + |> shouldSucceed + + use stream = File.OpenRead(Path.ChangeExtension(result.OutputPath.Value, "pdb")) + use provider = MetadataReaderProvider.FromPortablePdbStream stream + let pdb = provider.GetMetadataReader() + + result |> withMetadataReader (fun md -> + let method = + md.MethodDefinitions + |> Seq.filter (fun handle -> md.GetString(md.GetMethodDefinition(handle).Name) = "run") + |> Assert.Single + let localsAt (method: MethodDefinitionHandle) line = + let point = + pdb.GetMethodDebugInformation(method).GetSequencePoints() + |> Seq.filter (fun point -> not point.IsHidden && point.StartLine = line) + |> Assert.Single + [ for handle in pdb.GetLocalScopes method do + let scope = pdb.GetLocalScope handle + if scope.StartOffset <= point.Offset && point.Offset < scope.EndOffset then + for local in scope.GetLocalVariables() do + let variable = pdb.GetLocalVariable local + yield pdb.GetString variable.Name, variable ] + let outerLocals = localsAt method 14 + Assert.DoesNotContain("count (shadowed)", outerLocals |> List.map fst) + let outer = + outerLocals + |> List.filter (fun (name, _) -> name = "count") + |> Assert.Single + |> snd + let inner = + localsAt method 16 + |> List.filter (fun (name, _) -> name = "count") + |> Assert.Single + |> snd + Assert.NotEqual(outer.Index, inner.Index) + Assert.Equal(LocalVariableAttributes.None, inner.Attributes) + let elseLocals = localsAt method 22 + Assert.DoesNotContain("count (shadowed)", elseLocals |> List.map fst) + let restoredOuter = + elseLocals + |> List.filter (fun (name, _) -> name = "count") + |> Assert.Single + |> snd + Assert.Equal(outer.Index, restoredOuter.Index) + let invocationMethod = + md.MethodDefinitions + |> Seq.filter (fun handle -> + pdb.GetMethodDebugInformation(handle).GetSequencePoints() + |> Seq.exists (fun point -> not point.IsHidden && point.StartLine = 19)) + |> Assert.Single + let invoked = + localsAt invocationMethod 19 + |> List.filter (fun (name, _) -> name = "count") + |> Assert.Single + |> snd + Assert.Equal(LocalVariableAttributes.None, invoked.Attributes) + if insideContext then + Assert.Equal(inner.Index, invoked.Index)) + if insideContext then + result |> verifyILNotPresent [ "FSharpRef" ] #endif From fa64b3ebfbd311b025112fbbcba30a2fbd015baa Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:34:07 +0200 Subject: [PATCH 26/30] Separate runtime-async callback lowering and unify delegate preparation --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- docs/runtime-async.md | 13 +- src/Compiler/Optimize/LowerRuntimeAsync.fs | 444 ++++++++++++++++- src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 459 +----------------- .../Optimize/RuntimeAsyncAnalysis.fsi | 3 - src/Compiler/TypedTree/RuntimeAsync.fs | 13 + src/Compiler/TypedTree/RuntimeAsync.fsi | 8 + 7 files changed, 480 insertions(+), 462 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 54145c84928..2b43d0e96ac 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers; also lower conditional receivers left directly on `Invoke` after optimization, while rejecting suspensions in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Shared debug-wrapper handling preserves callback statement points and captured mutable locals' lexical scopes during construction and invocation without adding reference-cell allocations. Codegen shares marker preparation and closure validation. Stack guards protect callback reconstruction, capture analysis, and suspension-flow analysis for deeply nested bodies. Branch sizes and captures are cached, and callback uses, single-use delegate eligibility, and the existing copy budget are checked before expanding invocations. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers; also lower conditional receivers left directly on `Invoke` after optimization, while rejecting suspensions in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Shared debug-wrapper handling preserves callback statement points and captured mutable locals' lexical scopes during construction and invocation without adding reference-cell allocations. Codegen shares marker preparation and closure validation. Stack guards protect callback reconstruction, capture analysis, and suspension-flow analysis for deeply nested bodies. Branch sizes and captures are cached, and callback uses and the existing copy budget are checked before expanding invocations. Single-use delegates share one construction analysis for eligibility and invocation rewriting. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Prevent `InvalidProgramException` when runtime async awaits a `Task` upcast to `Task`, including through `ConfigureAwait`. ([Issue #20686](https://github.com/dotnet/fsharp/issues/20686), [PR #20726](https://github.com/dotnet/fsharp/pull/20726)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * Build parser diagnostic messages without runtime printf specialization or reflective function construction, so Native AOT hosts report indentation and invalid-character diagnostics instead of failing during formatting. Preserve `#r` reference tooltips while script project options remain alive, including after garbage collection or cache eviction. ([PR #20658](https://github.com/dotnet/fsharp/pull/20658)) diff --git a/docs/runtime-async.md b/docs/runtime-async.md index 995e561eb24..282b287308e 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -169,6 +169,12 @@ so it sees the final inlined shape rather than an intermediate optimizer state, and before `LowerLocalMutables`, so mutable locals it introduces that closures capture are promoted to reference cells. It has two steps. +Callback-use analysis, capture hoisting, and invocation rewriting live in +`LowerRuntimeAsync.fs`. `RuntimeAsyncAnalysis.fs` supplies fragment detection, +optimizer-side returned-closure reduction, and suspension-lifetime analysis. +Both transformations share debug-wrapper and match-target rebuilding helpers +from `TypedTree/RuntimeAsync.fs`. + First, if a compiler-owned `InlineIfLambda` function or delegate still contains a suspension, a fully rewritable, single-argument callback is inlined into the enclosing method. The ordinary optimizer already inlines a @@ -214,8 +220,11 @@ opaque consumers and quotations retaining the callback reject the candidate, and invocations nested in arguments count towards the same 2000-expression-node copy budget. The total cost is checked before copying any branch bodies. A rejected callback is left as a closure and a suspension in -it is reported as FS3918. Single-use delegate inlining also checks use count -and effect-free construction before rewriting its invocation. +it is reported as FS3918. Single-use delegate inlining checks use count, then +analyzes effect-free construction once to prepare its invocation rewrite. +The prepared rewrite rebuilds expressions only after the entire construction +has been accepted, so eligibility and rewriting cannot disagree about +supported construction shapes. Second, every return-marker body is prepared once after callbacks are inlined, innermost first: locals that cannot be preserved across a suspension are diff --git a/src/Compiler/Optimize/LowerRuntimeAsync.fs b/src/Compiler/Optimize/LowerRuntimeAsync.fs index caaac89a4e6..254f6849027 100644 --- a/src/Compiler/Optimize/LowerRuntimeAsync.fs +++ b/src/Compiler/Optimize/LowerRuntimeAsync.fs @@ -3,18 +3,460 @@ module internal FSharp.Compiler.LowerRuntimeAsync open System.Collections.Concurrent +open System.Collections.Generic +open Internal.Utilities.Collections open Internal.Utilities.Library +open Internal.Utilities.Library.Extras + open FSharp.Compiler open FSharp.Compiler.DiagnosticsLogger open FSharp.Compiler.Features open FSharp.Compiler.RuntimeAsync open FSharp.Compiler.RuntimeAsyncAnalysis open FSharp.Compiler.RuntimeAsyncExceptionRewrite +open FSharp.Compiler.Syntax open FSharp.Compiler.TcGlobals open FSharp.Compiler.Text open FSharp.Compiler.TypedTree +open FSharp.Compiler.TypedTreeBasics open FSharp.Compiler.TypedTreeOps +open FSharp.Compiler.TypeRelations + +let private isQuotationUsing (v: Val) expr = + match expr with + | Expr.Quote(body, _, _, _, _) -> + ExistsExpr + (function + | Expr.Val(vref, _, _) -> valEq v vref.Deref + | _ -> false) + body + | _ -> false + +let private tryCallbackInvocation (g: TcGlobals) (callback: Val) isDelegate expression = + match expression with + | Expr.App(Expr.Val(vref, _, _), _, [], [ arg ], m) when not isDelegate && valEq callback vref.Deref -> ValueSome(struct (arg, m)) + | DelegateInvokeExpr g (_, _, _, Expr.Val(vref, _, _), arg, m) when isDelegate && valEq callback vref.Deref -> + ValueSome(struct (arg, m)) + | _ -> ValueNone + +let private tryAnalyzeCallbackUses g callback isDelegate continuation = + let folder = + { ExprFolder0 with + exprIntercept = + fun recurse noIntercept uses expression -> + match uses with + | ValueNone -> ValueNone + | ValueSome count -> + match tryCallbackInvocation g callback isDelegate expression with + | ValueSome(struct (arg, _)) -> recurse (ValueSome(count + 1)) arg + | ValueNone -> + match expression with + | Expr.Val(vref, _, _) when valEq callback vref.Deref -> ValueNone + | Expr.Quote(_, dataCell, _, _, _) -> + if isQuotationUsing callback expression then + ValueNone + else + match dataCell.Value with + | Some((_, _, args, _), (_, _, legacyArgs, _)) -> + List.fold recurse (List.fold recurse uses args) legacyArgs + | None -> uses + | _ -> noIntercept uses expression + } + + FoldExpr folder (ValueSome 0) continuation + +type private CallbackBranch = + { + Parameter: Val + Body: Expr + Captures: Val list + NodeCount: int + } + +/// A callback construction whose branches select one of several lambdas. The construction runs once +/// and records the selected branch and the values it captures; each invocation dispatches on the branch. +type private CallbackBranches = + { + Construction: Expr + Tag: Val + Branches: CallbackBranch list + Captures: (Val * Val) list + } + +/// A captured mutable keeps a single shared home: its binding becomes an assignment to the hoisted local and +/// every other use is redirected to it, so writes made by the inlined callback stay visible to other closures. +/// Returns None if a captured mutable is not bound by a let in the construction. +let private tryShareMutableCallbackCaptures (g: TcGlobals) (mutableCaptures: (Val * Val) list) construction = + if mutableCaptures.IsEmpty then + Some construction + else + let hoistedOf = + ValMap.OfList(mutableCaptures |> List.map (fun (v, hoisted) -> v, mkLocalValRef hoisted)) + + let rewritten = HashSet() + + let rwenv = + { + PreIntercept = + Some(fun rewrite expression -> + match expression with + | Expr.Let(TBind(v, rhs, point), body, m, _) when hoistedOf.ContainsVal v -> + rewritten.Add v.Stamp |> ignore + let assignment = mkValSet m hoistedOf[v] (rewrite rhs) + + let assignment = + match point with + | DebugPointAtBinding.Yes point -> mkDebugPoint point assignment + | _ -> assignment + + let body = rewrite body + + let body = + if v.IsCompilerGenerated then + body + else + Expr.Op(TOp.DebugLocalScope(hoistedOf[v], v.LogicalName), [], [ body ], m) + + Some(mkCompGenSequential m assignment body) + | _ -> None) + PreInterceptBinding = None + PostTransform = (fun _ -> None) + RewriteQuotations = false + StackGuard = StackGuard("RewriteRuntimeAsyncMutableCaptures") + } + + let construction = RewriteExpr rwenv construction + + if rewritten.Count = mutableCaptures.Length then + Some(remapExpr g CloneAll { emptyRemap with valRemap = hoistedOf } construction) + else + None + +let private exprNodeCount expr = + let folder = + { ExprFolder0 with + exprIntercept = fun _ noInterceptF count expr -> noInterceptF (count + 1) expr + } + + FoldExpr folder 0 expr + +/// Replaces each lambda in tail position of a callback construction with an assignment of its branch tag +/// and captured locals, or returns None if a tail is not a supported single-argument lambda. +let private tryDefunctionalizeCallback (g: TcGlobals) (stackGuard: StackGuard) m construction = + let freeVarOptions = CollectLocalsWithStackGuard() + let constructionFree = (freeInExpr freeVarOptions construction).FreeLocals + let tag, _ = mkMutableCompGenLocal m "runtimeAsyncCallbackTag" g.int32_ty + let branches = ResizeArray() + let captures = Dictionary() + + let rec defunctionalize expr = + stackGuard.Guard(fun () -> defunctionalizeCore expr) + + and defunctionalizeCore expr = + match expr with + | Expr.Lambda(_, None, None, [ parameter ], body, m, _) + | NewDelegateExpr g (_, [ parameter ], body, m, _) -> + let captured = + (freeInExpr freeVarOptions body).FreeLocals + |> Zset.elements + |> List.filter (fun v -> not (valEq v parameter) && not (Zset.contains v constructionFree)) + + if + captured + |> List.exists (fun v -> v.IsPinning || isByrefTy g v.Type || isByrefLikeTy g m v.Type) + then + None + else + let assignments = + captured + |> List.map (fun v -> + let _, hoisted = + match captures.TryGetValue v.Stamp with + | true, capture -> capture + | _ -> + let hoisted = + if v.IsMutable then + let hoisted = Construct.NewModifiedVal id v + hoisted.SetIsCompilerGenerated true + hoisted + else + fst (mkMutableCompGenLocal m v.LogicalName v.Type) + + let capture = v, hoisted + captures[v.Stamp] <- capture + capture + + if v.IsMutable then + None + else + Some(mkValSet m (mkLocalValRef hoisted) (exprForVal m v))) + |> List.choose id + + let select = mkValSet m (mkLocalValRef tag) (mkInt32 g m branches.Count) + + branches.Add + { + Parameter = parameter + Body = body + Captures = captured + NodeCount = exprNodeCount body + } + + Some(List.foldBack (mkCompGenSequential m) assignments select) + | RuntimeAsyncDebugWrapper body -> defunctionalize body |> Option.map (RebuildRuntimeAsyncDebugWrapper expr) + | Expr.Sequential(first, rest, NormalSeq, m) -> + defunctionalize rest + |> Option.map (fun rest -> Expr.Sequential(first, rest, NormalSeq, m)) + | Expr.Let(binding, rest, m, _) -> defunctionalize rest |> Option.map (mkLetBind m binding) + | Expr.Match(point, matchRange, tree, targets, m, _) -> + TryMapRuntimeAsyncMatchTargets g (point, matchRange, tree, targets, m) (fun _ -> defunctionalize) + | _ -> None + + defunctionalize construction + |> Option.bind (fun construction -> + let captures = List.ofSeq captures.Values + + tryShareMutableCallbackCaptures g (captures |> List.filter (fun (v, _) -> v.IsMutable)) construction + |> Option.map (fun construction -> + { + Construction = construction + Tag = tag + Branches = List.ofSeq branches + Captures = captures + })) + +/// Every invocation copies every branch body, so the rewrite is skipped when the copies would exceed this many +/// expression nodes; the callback then stays a closure and a suspension left in it is reported as FS3918. +let private maxInlinedCallbackCopySize = 2000 + +/// Inlines an invocation of a defunctionalized callback: the argument is bound once, then each branch body +/// is copied with its parameter and captured locals remapped. +let private mkCallbackDispatch (g: TcGlobals) (stackGuard: StackGuard) (callback: CallbackBranches) arg resultTy m = + let argVal, _ = mkCompGenLocal m "runtimeAsyncCallbackArg" (tyOfExpr g arg) + + let captureRemap = + callback.Captures + |> List.map (fun (v, hoisted) -> v, mkLocalValRef hoisted) + |> ValMap.OfList + + let branch branch = + let remap = + { emptyRemap with + valRemap = captureRemap.Add branch.Parameter (mkLocalValRef argVal) + } + + let body = remapExpr g CloneAll remap branch.Body + + (branch.Captures, body) + ||> List.foldBack (fun v body -> + if v.IsMutable && not v.IsCompilerGenerated then + Expr.Op(TOp.DebugLocalScope(captureRemap[v], v.LogicalName), [], [ body ], m) + else + body) + + let rec dispatch i branches = + stackGuard.Guard(fun () -> dispatchCore i branches) + + and dispatchCore i branches = + match branches with + | [ last ] -> branch last + | first :: rest -> + let isSelected = mkILAsmCeq g m (exprForVal m callback.Tag) (mkInt32 g m i) + + mkCond DebugPointAtBinding.NoneAtInvisible m resultTy isSelected (branch first) (dispatch (i + 1) rest) + | [] -> failwith "unreachable: callback without branches" + + mkCompGenLet m argVal arg (dispatch 0 callback.Branches) + +let private inlineCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext (expr: Expr) = + let stackGuard = StackGuard("InlineRuntimeAsyncCallback") + let freeVarOptions = CollectLocalsWithStackGuard() + + let rec tryDelegateSignature expr = + match expr with + | NewDelegateExpr g (_, [ parameter ], body, _, _) -> Some(parameter.Type, tyOfExpr g body) + | RuntimeAsyncDebugWrapper rest + | Expr.Sequential(_, rest, NormalSeq, _) + | Expr.Let(_, rest, _, _) -> tryDelegateSignature rest + | Expr.Match(_, _, _, targets, _, _) when targets.Length > 0 -> + let (TTarget(_, body, _)) = targets[0] + tryDelegateSignature body + | _ -> None + + let isTrivialValue = + function + | Expr.Const _ -> true + | Expr.Val(vref, _, _) -> vref.IsLocalRef && not vref.IsMutable && not vref.IsTypeFunction + | _ -> false + + // Construction moves to the invocation, so its evaluation must not have observable effects. + let rec tryPrepareDelegateInvocation construction = + stackGuard.Guard(fun () -> + let prepared = + match construction with + | NewDelegateExpr g (_, [ _ ], _, _, _) -> + ValueSome(fun (invokeRef, invokeTy, tyargs, arg, callRange) -> + MakeFSharpDelegateInvokeAndTryBetaReduce g (invokeRef, construction, invokeTy, tyargs, arg, callRange)) + | RuntimeAsyncDebugWrapper rest -> + tryPrepareDelegateInvocation rest + |> ValueOption.map (fun invoke -> invoke >> RebuildRuntimeAsyncDebugWrapper construction) + | Expr.Sequential(first, rest, NormalSeq, m) when isTrivialValue first -> + tryPrepareDelegateInvocation rest + |> ValueOption.map (fun invoke -> fun args -> Expr.Sequential(first, invoke args, NormalSeq, m)) + | Expr.Let((TBind(_, rhs, _)) as binding, rest, m, _) when isTrivialValue rhs -> + tryPrepareDelegateInvocation rest + |> ValueOption.map (fun invoke -> invoke >> mkLetBind m binding) + | Expr.Match(point, + matchRange, + (TDSwitch((Expr.Val _ as input), [ TCase(_, TDSuccess([], _)) ], Some(TDSuccess([], _)), _) as tree), + targets, + m, + _) when isTrivialValue input -> + let invocations = + targets + |> Array.map (fun (TTarget(_, body, _)) -> tryPrepareDelegateInvocation body) + + if targets.Length > 0 && Array.forall ValueOption.isSome invocations then + let invocations = Array.map ValueOption.get invocations + + ValueSome(fun args -> + let targets = + (targets, invocations) + ||> Array.map2 (fun (TTarget(vals, _, flags)) invoke -> TTarget(vals, invoke args, flags)) + + Expr.Match(point, matchRange, tree, targets, m, tyOfExpr g targets[0].TargetExpression)) + else + ValueNone + | _ -> ValueNone + + prepared + |> ValueOption.map (fun invoke -> fun args -> stackGuard.Guard(fun () -> invoke args))) + + // A residual Invoke can surface after ordinary optimization; try its single use before dispatching on branches. + let inlineDelegate callback invoke continuation = + let rwenv = + { + PreIntercept = + Some(fun rewrite expression -> + match expression with + | DelegateInvokeExpr g (invokeRef, invokeTy, tyargs, Expr.Val(vref, _, _), arg, callRange) when + valEq callback vref.Deref + -> + Some(invoke (invokeRef, invokeTy, tyargs, rewrite arg, callRange)) + | _ -> None) + PreInterceptBinding = None + PostTransform = (fun _ -> None) + RewriteQuotations = false + StackGuard = stackGuard + } + + RewriteExpr rwenv continuation + + let rec inlineCallbacks expr = + stackGuard.Guard(fun () -> inlineCallbacksCore expr) + + and inlineCallbacksCore expr = + match expr with + | Expr.Let(TBind(callback, construction, point), continuation, m, _) when + callback.InlineIfLambda && analyzer.ContainsSuspension construction + -> + let keep construction = + mkLetBind m (TBind(callback, construction, point)) (inlineCallbacks continuation) + + let canInline = + runtimeAsyncContext || (TryGetRuntimeAsyncReturn g continuation).IsSome + + let shape = + match tryDestFunTy g callback.Type with + | ValueSome(argTy, resultTy) -> Some(argTy, resultTy, false) + | ValueNone when isFSharpDelegateTy g callback.Type -> + tryDelegateSignature construction + |> Option.map (fun (argTy, resultTy) -> argTy, resultTy, true) + | _ -> None + + match shape with + | Some(argTy, resultTy, isDelegate) when canInline && not (isByrefLikeTy g m argTy || isByrefLikeTy g m resultTy) -> + let freeVals = (freeInExpr freeVarOptions construction).FreeLocals + + let canCapture = + freeVals + |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) + + let construction = + if canCapture then + inlineCallbacks construction + else + construction + + let callbackUses = + if canCapture then + tryAnalyzeCallbackUses g callback isDelegate continuation + else + ValueNone + + let inlined = + match callbackUses with + | ValueSome 1 when isDelegate -> + tryPrepareDelegateInvocation construction + |> ValueOption.map (fun invoke -> inlineDelegate callback invoke continuation) + |> ValueOption.toOption + | _ -> None + + let callbackBranches = + match callbackUses with + | ValueSome invocations when Option.isNone inlined -> + tryDefunctionalizeCallback g stackGuard m construction + |> Option.filter (fun branches -> + invocations = 0 + || List.sumBy _.NodeCount branches.Branches + <= maxInlinedCallbackCopySize / invocations) + | _ -> None + + match inlined, callbackBranches with + | Some body, _ -> inlineCallbacks body + | None, Some callbackBranches -> + let rwenv = + { + PreIntercept = + Some(fun rewrite expression -> + match tryCallbackInvocation g callback isDelegate expression with + | ValueSome(struct (arg, callRange)) -> + Some( + mkCallbackDispatch + g + stackGuard + callbackBranches + (rewrite arg) + (tyOfExpr g expression) + callRange + ) + | ValueNone -> None) + PreInterceptBinding = None + PostTransform = (fun _ -> None) + RewriteQuotations = false + StackGuard = stackGuard + } + + let continuation = RewriteExpr rwenv continuation + + let construction = + match point with + | DebugPointAtBinding.Yes point -> mkDebugPoint point callbackBranches.Construction + | _ -> callbackBranches.Construction + + let body = mkCompGenSequential m construction (inlineCallbacks continuation) + + (callbackBranches.Tag :: List.map snd callbackBranches.Captures, body) + ||> List.foldBack (fun v body -> mkCompGenLet m v (mkDefault (m, v.Type)) body) + | None, None -> keep construction + | _ -> keep construction + | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (inlineCallbacks continuation) + | RuntimeAsyncDebugWrapper body -> RebuildRuntimeAsyncDebugWrapper expr (inlineCallbacks body) + | Expr.Sequential(first, rest, NormalSeq, m) -> Expr.Sequential(first, inlineCallbacks rest, NormalSeq, m) + | _ -> expr + + inlineCallbacks expr let private isRuntimeAsyncEntry g expr = (TryGetRuntimeAsyncReturn g expr).IsSome @@ -87,7 +529,7 @@ let private inlineCallbacks g implFile = and postTransform inContext expr = match expr with | Expr.Let(TBind(callback, construction, _), _, _, _) when callback.InlineIfLambda && analyzer.ContainsSuspension construction -> - Some(InlineRuntimeAsyncCallback g analyzer inContext expr) + Some(inlineCallback g analyzer inContext expr) | _ -> None RewriteImplFile (rewriter false) implFile diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 3b94c76b3e2..5d98dc1b314 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -2,23 +2,19 @@ module internal FSharp.Compiler.RuntimeAsyncAnalysis +open System.Collections.Generic + open Internal.Utilities.Collections open Internal.Utilities.Library open Internal.Utilities.Library.Extras -open System.Collections.Generic - open FSharp.Compiler open FSharp.Compiler.DiagnosticsLogger -open FSharp.Compiler.Syntax +open FSharp.Compiler.RuntimeAsync open FSharp.Compiler.TcGlobals -open FSharp.Compiler.Text open FSharp.Compiler.TypedTree open FSharp.Compiler.TypedTreeBasics open FSharp.Compiler.TypedTreeOps -open FSharp.Compiler.TypeRelations - -open FSharp.Compiler.RuntimeAsync let rec private containsRecipeConstruction visit expr = match stripDebugPoints expr with @@ -146,61 +142,6 @@ let ShouldForceRuntimeAsyncApplication (analyzer: RuntimeAsyncAnalyzer) runtimeA | _ -> false) args) -/// Rebuilds a match with every target rewritten, or returns None if any target cannot be. -let private tryMapMatchTargets (g: TcGlobals) (point, matchRange, tree, targets: DecisionTreeTarget array, m) mapTarget = - let targets = - targets - |> Array.mapi (fun i (TTarget(vals, body, flags)) -> mapTarget i body |> Option.map (fun body -> TTarget(vals, body, flags))) - - if targets.Length > 0 && Array.forall Option.isSome targets then - let targets = Array.map Option.get targets - Some(Expr.Match(point, matchRange, tree, targets, m, tyOfExpr g targets[0].TargetExpression)) - else - None - -let private isQuotationUsing (v: Val) expr = - match expr with - | Expr.Quote(body, _, _, _, _) -> - ExistsExpr - (function - | Expr.Val(vref, _, _) -> valEq v vref.Deref - | _ -> false) - body - | _ -> false - -let private tryCallbackInvocation (g: TcGlobals) (callback: Val) isDelegate expression = - match expression with - | Expr.App(Expr.Val(vref, _, _), _, [], [ arg ], m) when not isDelegate && valEq callback vref.Deref -> ValueSome(struct (arg, m)) - | DelegateInvokeExpr g (_, _, _, Expr.Val(vref, _, _), arg, m) when isDelegate && valEq callback vref.Deref -> - ValueSome(struct (arg, m)) - | _ -> ValueNone - -let private tryAnalyzeCallbackUses g callback isDelegate continuation = - let folder = - { ExprFolder0 with - exprIntercept = - fun recurse noIntercept uses expression -> - match uses with - | ValueNone -> ValueNone - | ValueSome count -> - match tryCallbackInvocation g callback isDelegate expression with - | ValueSome(struct (arg, _)) -> recurse (ValueSome(count + 1)) arg - | ValueNone -> - match expression with - | Expr.Val(vref, _, _) when valEq callback vref.Deref -> ValueNone - | Expr.Quote(_, dataCell, _, _, _) -> - if isQuotationUsing callback expression then - ValueNone - else - match dataCell.Value with - | Some((_, _, args, _), (_, _, legacyArgs, _)) -> - List.fold recurse (List.fold recurse uses args) legacyArgs - | None -> uses - | _ -> noIntercept uses expression - } - - FoldExpr folder (ValueSome 0) continuation - let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) expr = let stackGuard = StackGuard("ReduceRuntimeAsyncReturnedClosureApplications") @@ -239,7 +180,7 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt |> Option.map (fun rest -> Expr.Sequential(first, rest, NormalSeq, mSeq)) | Expr.Match(point, matchRange, tree, targets, mMatch, _), _ when List.forall effectFree args -> // Each target after the first needs its own copy of any values bound by the arguments. - tryMapMatchTargets g (point, matchRange, tree, targets, mMatch) (fun i body -> + TryMapRuntimeAsyncMatchTargets g (point, matchRange, tree, targets, mMatch) (fun i body -> let args = if i = 0 then args else List.map (copyExpr g CloneAll) args apply body (tyOfExpr g body) tyargs args m) | Expr.Lambda(_, _, _, [ _ ], _, _, _), first :: (_ :: _ as rest) when List.forall effectFree rest -> @@ -287,398 +228,6 @@ let PreserveRuntimeAsyncCallSiteDebugPoint (g: TcGlobals) m expr = RewriteExpr rwenv expr -type private CallbackBranch = - { - Parameter: Val - Body: Expr - Captures: Val list - NodeCount: int - } - -/// A callback construction whose branches select one of several lambdas. The construction runs once -/// and records the selected branch and the values it captures; each invocation dispatches on the branch. -type private CallbackBranches = - { - Construction: Expr - Tag: Val - Branches: CallbackBranch list - Captures: (Val * Val) list - } - -/// A captured mutable keeps a single shared home: its binding becomes an assignment to the hoisted local and -/// every other use is redirected to it, so writes made by the inlined callback stay visible to other closures. -/// Returns None if a captured mutable is not bound by a let in the construction. -let private tryShareMutableCallbackCaptures (g: TcGlobals) (mutableCaptures: (Val * Val) list) construction = - if mutableCaptures.IsEmpty then - Some construction - else - let hoistedOf = - ValMap.OfList(mutableCaptures |> List.map (fun (v, hoisted) -> v, mkLocalValRef hoisted)) - - let rewritten = HashSet() - - let rwenv = - { - PreIntercept = - Some(fun rewrite expression -> - match expression with - | Expr.Let(TBind(v, rhs, point), body, m, _) when hoistedOf.ContainsVal v -> - rewritten.Add v.Stamp |> ignore - let assignment = mkValSet m hoistedOf[v] (rewrite rhs) - - let assignment = - match point with - | DebugPointAtBinding.Yes point -> mkDebugPoint point assignment - | _ -> assignment - - let body = rewrite body - - let body = - if v.IsCompilerGenerated then - body - else - Expr.Op(TOp.DebugLocalScope(hoistedOf[v], v.LogicalName), [], [ body ], m) - - Some(mkCompGenSequential m assignment body) - | _ -> None) - PreInterceptBinding = None - PostTransform = (fun _ -> None) - RewriteQuotations = false - StackGuard = StackGuard("RewriteRuntimeAsyncMutableCaptures") - } - - let construction = RewriteExpr rwenv construction - - if rewritten.Count = mutableCaptures.Length then - Some(remapExpr g CloneAll { emptyRemap with valRemap = hoistedOf } construction) - else - None - -let private exprNodeCount expr = - let folder = - { ExprFolder0 with - exprIntercept = fun _ noInterceptF count expr -> noInterceptF (count + 1) expr - } - - FoldExpr folder 0 expr - -/// Replaces each lambda in tail position of a callback construction with an assignment of its branch tag -/// and captured locals, or returns None if a tail is not a supported single-argument lambda. -let private tryDefunctionalizeCallback (g: TcGlobals) (stackGuard: StackGuard) m construction = - let freeVarOptions = CollectLocalsWithStackGuard() - let constructionFree = (freeInExpr freeVarOptions construction).FreeLocals - let tag, _ = mkMutableCompGenLocal m "runtimeAsyncCallbackTag" g.int32_ty - let branches = ResizeArray() - let captures = Dictionary() - - let rec defunctionalize expr = - stackGuard.Guard(fun () -> defunctionalizeCore expr) - - and defunctionalizeCore expr = - match expr with - | Expr.Lambda(_, None, None, [ parameter ], body, m, _) - | NewDelegateExpr g (_, [ parameter ], body, m, _) -> - let captured = - (freeInExpr freeVarOptions body).FreeLocals - |> Zset.elements - |> List.filter (fun v -> not (valEq v parameter) && not (Zset.contains v constructionFree)) - - if - captured - |> List.exists (fun v -> v.IsPinning || isByrefTy g v.Type || isByrefLikeTy g m v.Type) - then - None - else - let assignments = - captured - |> List.map (fun v -> - let _, hoisted = - match captures.TryGetValue v.Stamp with - | true, capture -> capture - | _ -> - let hoisted = - if v.IsMutable then - let hoisted = Construct.NewModifiedVal id v - hoisted.SetIsCompilerGenerated true - hoisted - else - fst (mkMutableCompGenLocal m v.LogicalName v.Type) - - let capture = v, hoisted - captures[v.Stamp] <- capture - capture - - if v.IsMutable then - None - else - Some(mkValSet m (mkLocalValRef hoisted) (exprForVal m v))) - |> List.choose id - - let select = mkValSet m (mkLocalValRef tag) (mkInt32 g m branches.Count) - - branches.Add - { - Parameter = parameter - Body = body - Captures = captured - NodeCount = exprNodeCount body - } - - Some(List.foldBack (mkCompGenSequential m) assignments select) - | RuntimeAsyncDebugWrapper body -> defunctionalize body |> Option.map (RebuildRuntimeAsyncDebugWrapper expr) - | Expr.Sequential(first, rest, NormalSeq, m) -> - defunctionalize rest - |> Option.map (fun rest -> Expr.Sequential(first, rest, NormalSeq, m)) - | Expr.Let(binding, rest, m, _) -> defunctionalize rest |> Option.map (mkLetBind m binding) - | Expr.Match(point, matchRange, tree, targets, m, _) -> - tryMapMatchTargets g (point, matchRange, tree, targets, m) (fun _ -> defunctionalize) - | _ -> None - - defunctionalize construction - |> Option.bind (fun construction -> - let captures = List.ofSeq captures.Values - - tryShareMutableCallbackCaptures g (captures |> List.filter (fun (v, _) -> v.IsMutable)) construction - |> Option.map (fun construction -> - { - Construction = construction - Tag = tag - Branches = List.ofSeq branches - Captures = captures - })) - -/// Every invocation copies every branch body, so the rewrite is skipped when the copies would exceed this many -/// expression nodes; the callback then stays a closure and a suspension left in it is reported as FS3918. -let private maxInlinedCallbackCopySize = 2000 - -/// Inlines an invocation of a defunctionalized callback: the argument is bound once, then each branch body -/// is copied with its parameter and captured locals remapped. -let private mkCallbackDispatch (g: TcGlobals) (stackGuard: StackGuard) (callback: CallbackBranches) arg resultTy m = - let argVal, _ = mkCompGenLocal m "runtimeAsyncCallbackArg" (tyOfExpr g arg) - - let captureRemap = - callback.Captures - |> List.map (fun (v, hoisted) -> v, mkLocalValRef hoisted) - |> ValMap.OfList - - let branch branch = - let remap = - { emptyRemap with - valRemap = captureRemap.Add branch.Parameter (mkLocalValRef argVal) - } - - let body = remapExpr g CloneAll remap branch.Body - - (branch.Captures, body) - ||> List.foldBack (fun v body -> - if v.IsMutable && not v.IsCompilerGenerated then - Expr.Op(TOp.DebugLocalScope(captureRemap[v], v.LogicalName), [], [ body ], m) - else - body) - - let rec dispatch i branches = - stackGuard.Guard(fun () -> dispatchCore i branches) - - and dispatchCore i branches = - match branches with - | [ last ] -> branch last - | first :: rest -> - let isSelected = mkILAsmCeq g m (exprForVal m callback.Tag) (mkInt32 g m i) - - mkCond DebugPointAtBinding.NoneAtInvisible m resultTy isSelected (branch first) (dispatch (i + 1) rest) - | [] -> failwith "unreachable: callback without branches" - - mkCompGenLet m argVal arg (dispatch 0 callback.Branches) - -let InlineRuntimeAsyncCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext (expr: Expr) = - let stackGuard = StackGuard("InlineRuntimeAsyncCallback") - let freeVarOptions = CollectLocalsWithStackGuard() - - let rec tryDelegateSignature expr = - match expr with - | NewDelegateExpr g (_, [ parameter ], body, _, _) -> Some(parameter.Type, tyOfExpr g body) - | RuntimeAsyncDebugWrapper rest - | Expr.Sequential(_, rest, NormalSeq, _) - | Expr.Let(_, rest, _, _) -> tryDelegateSignature rest - | Expr.Match(_, _, _, targets, _, _) when targets.Length > 0 -> - let (TTarget(_, body, _)) = targets[0] - tryDelegateSignature body - | _ -> None - - let isTrivialValue = - function - | Expr.Const _ -> true - | Expr.Val(vref, _, _) -> vref.IsLocalRef && not vref.IsMutable && not vref.IsTypeFunction - | _ -> false - - let rec canInlineDelegateConstruction construction = - stackGuard.Guard(fun () -> canInlineDelegateConstructionCore construction) - - and canInlineDelegateConstructionCore construction = - match construction with - | NewDelegateExpr g (_, [ _ ], _, _, _) -> true - | RuntimeAsyncDebugWrapper rest -> canInlineDelegateConstruction rest - | Expr.Sequential(first, rest, NormalSeq, _) when isTrivialValue first -> canInlineDelegateConstruction rest - | Expr.Let(TBind(_, rhs, _), rest, _, _) when isTrivialValue rhs -> canInlineDelegateConstruction rest - | Expr.Match(_, _, TDSwitch(Expr.Val(vref, _, _), [ TCase(_, TDSuccess([], _)) ], Some(TDSuccess([], _)), _), targets, _, _) when - vref.IsLocalRef && not vref.IsMutable && not vref.IsTypeFunction - -> - targets.Length > 0 - && Array.forall (fun (TTarget(_, body, _)) -> canInlineDelegateConstruction body) targets - | _ -> false - - // Construction moves to the invocation, so its evaluation must not have observable effects. - let rec inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) construction = - stackGuard.Guard(fun () -> inlineDelegateInvokeCore (invokeRef, invokeTy, tyargs, arg, callRange) construction) - - and inlineDelegateInvokeCore (invokeRef, invokeTy, tyargs, arg, callRange) construction = - match construction with - | NewDelegateExpr g (_, [ _ ], _, _, _) -> - Some(MakeFSharpDelegateInvokeAndTryBetaReduce g (invokeRef, construction, invokeTy, tyargs, arg, callRange)) - | RuntimeAsyncDebugWrapper rest -> - inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest - |> Option.map (RebuildRuntimeAsyncDebugWrapper construction) - | Expr.Sequential(first, rest, NormalSeq, m) when isTrivialValue first -> - inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest - |> Option.map (fun body -> Expr.Sequential(first, body, NormalSeq, m)) - | Expr.Let((TBind(_, rhs, _)) as binding, rest, m, _) when isTrivialValue rhs -> - inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange) rest - |> Option.map (mkLetBind m binding) - | Expr.Match(point, - matchRange, - (TDSwitch(Expr.Val(vref, _, _), [ TCase(_, TDSuccess([], _)) ], Some(TDSuccess([], _)), _) as tree), - targets, - m, - _) when vref.IsLocalRef && not vref.IsMutable && not vref.IsTypeFunction -> - tryMapMatchTargets g (point, matchRange, tree, targets, m) (fun _ -> - inlineDelegateInvoke (invokeRef, invokeTy, tyargs, arg, callRange)) - | _ -> None - - // A residual Invoke can surface after ordinary optimization; try its single use before dispatching on branches. - let inlineDelegate callback construction continuation = - let rwenv = - { - PreIntercept = - Some(fun rewrite expression -> - match expression with - | DelegateInvokeExpr g (invokeRef, invokeTy, tyargs, Expr.Val(vref, _, _), arg, callRange) when - valEq callback vref.Deref - -> - inlineDelegateInvoke (invokeRef, invokeTy, tyargs, rewrite arg, callRange) construction - |> Option.defaultWith (fun () -> failwith "unreachable: validated runtime-async delegate construction") - |> Some - | _ -> None) - PreInterceptBinding = None - PostTransform = (fun _ -> None) - RewriteQuotations = false - StackGuard = stackGuard - } - - RewriteExpr rwenv continuation - - let rec inlineCallbacks expr = - stackGuard.Guard(fun () -> inlineCallbacksCore expr) - - and inlineCallbacksCore expr = - match expr with - | Expr.Let(TBind(callback, construction, point), continuation, m, _) when - callback.InlineIfLambda && analyzer.ContainsSuspension construction - -> - let keep construction = - mkLetBind m (TBind(callback, construction, point)) (inlineCallbacks continuation) - - let canInline = - runtimeAsyncContext || (TryGetRuntimeAsyncReturn g continuation).IsSome - - let shape = - match tryDestFunTy g callback.Type with - | ValueSome(argTy, resultTy) -> Some(argTy, resultTy, false) - | ValueNone when isFSharpDelegateTy g callback.Type -> - tryDelegateSignature construction - |> Option.map (fun (argTy, resultTy) -> argTy, resultTy, true) - | _ -> None - - match shape with - | Some(argTy, resultTy, isDelegate) when canInline && not (isByrefLikeTy g m argTy || isByrefLikeTy g m resultTy) -> - let freeVals = (freeInExpr freeVarOptions construction).FreeLocals - - let canCapture = - freeVals - |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) - - let construction = - if canCapture then - inlineCallbacks construction - else - construction - - let callbackUses = - if canCapture then - tryAnalyzeCallbackUses g callback isDelegate continuation - else - ValueNone - - let inlined = - match callbackUses with - | ValueSome 1 when isDelegate && canInlineDelegateConstruction construction -> - Some(inlineDelegate callback construction continuation) - | _ -> None - - let callbackBranches = - match callbackUses with - | ValueSome invocations when Option.isNone inlined -> - tryDefunctionalizeCallback g stackGuard m construction - |> Option.filter (fun branches -> - invocations = 0 - || List.sumBy _.NodeCount branches.Branches - <= maxInlinedCallbackCopySize / invocations) - | _ -> None - - match inlined, callbackBranches with - | Some body, _ -> inlineCallbacks body - | None, Some callbackBranches -> - let rwenv = - { - PreIntercept = - Some(fun rewrite expression -> - match tryCallbackInvocation g callback isDelegate expression with - | ValueSome(struct (arg, callRange)) -> - Some( - mkCallbackDispatch - g - stackGuard - callbackBranches - (rewrite arg) - (tyOfExpr g expression) - callRange - ) - | ValueNone -> None) - PreInterceptBinding = None - PostTransform = (fun _ -> None) - RewriteQuotations = false - StackGuard = stackGuard - } - - let continuation = RewriteExpr rwenv continuation - - let construction = - match point with - | DebugPointAtBinding.Yes point -> mkDebugPoint point callbackBranches.Construction - | _ -> callbackBranches.Construction - - let body = mkCompGenSequential m construction (inlineCallbacks continuation) - - (callbackBranches.Tag :: List.map snd callbackBranches.Captures, body) - ||> List.foldBack (fun v body -> mkCompGenLet m v (mkDefault (m, v.Type)) body) - | None, None -> keep construction - | _ -> keep construction - | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (inlineCallbacks continuation) - | RuntimeAsyncDebugWrapper body -> RebuildRuntimeAsyncDebugWrapper expr (inlineCallbacks body) - | Expr.Sequential(first, rest, NormalSeq, m) -> Expr.Sequential(first, inlineCallbacks rest, NormalSeq, m) - | _ -> expr - - inlineCallbacks expr - type private RuntimeAsyncFlowSummary = { MaySuspend: bool diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi index 31db4214b68..015ebbc0346 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fsi @@ -24,9 +24,6 @@ val ShouldForceRuntimeAsyncApplication: args: Expr list -> bool -val InlineRuntimeAsyncCallback: - g: TcGlobals -> analyzer: RuntimeAsyncAnalyzer -> runtimeAsyncContext: bool -> expr: Expr -> Expr - val ReduceRuntimeAsyncReturnedClosureApplications: g: TcGlobals -> analyzer: RuntimeAsyncAnalyzer -> expr: Expr -> Expr val PreserveRuntimeAsyncCallSiteDebugPoint: g: TcGlobals -> m: FSharp.Compiler.Text.range -> expr: Expr -> Expr diff --git a/src/Compiler/TypedTree/RuntimeAsync.fs b/src/Compiler/TypedTree/RuntimeAsync.fs index 3dfab34312f..90569f688e0 100644 --- a/src/Compiler/TypedTree/RuntimeAsync.fs +++ b/src/Compiler/TypedTree/RuntimeAsync.fs @@ -22,6 +22,19 @@ let RebuildRuntimeAsyncDebugWrapper wrapper body = | Expr.Op(TOp.DebugLocalScope _ as op, [], [ _ ], m) -> Expr.Op(op, [], [ body ], m) | _ -> failwith "unreachable: expression is not a runtime-async debug wrapper" +let TryMapRuntimeAsyncMatchTargets (g: TcGlobals) matchInfo mapTarget = + let point, matchRange, tree, (targets: DecisionTreeTarget array), m = matchInfo + + let targets = + targets + |> Array.mapi (fun i (TTarget(vals, body, flags)) -> mapTarget i body |> Option.map (fun body -> TTarget(vals, body, flags))) + + if targets.Length > 0 && Array.forall Option.isSome targets then + let targets = Array.map Option.get targets + Some(Expr.Match(point, matchRange, tree, targets, m, tyOfExpr g targets[0].TargetExpression)) + else + None + let (|RuntimeAsyncReturn|_|) (g: TcGlobals) (vref: ValRef) = valRefEq g vref g.cgh__runtimeAsyncReturn_vref || valRefEq g vref g.cgh__runtimeAsyncReturnValueTask_vref diff --git a/src/Compiler/TypedTree/RuntimeAsync.fsi b/src/Compiler/TypedTree/RuntimeAsync.fsi index ba7fcea819f..016a2863167 100644 --- a/src/Compiler/TypedTree/RuntimeAsync.fsi +++ b/src/Compiler/TypedTree/RuntimeAsync.fsi @@ -3,6 +3,7 @@ module internal FSharp.Compiler.RuntimeAsync open FSharp.Compiler.AbstractIL.IL +open FSharp.Compiler.Syntax open FSharp.Compiler.TcGlobals open FSharp.Compiler.Text open FSharp.Compiler.TypedTree @@ -14,6 +15,13 @@ val (|RuntimeAsyncDebugWrapper|_|): expr: Expr -> Expr voption /// Retains the original debug range or lexical scope when rebuilding a matched wrapper. val RebuildRuntimeAsyncDebugWrapper: wrapper: Expr -> body: Expr -> Expr +/// Rebuilds a match with every target rewritten, or returns None if any target cannot be. +val TryMapRuntimeAsyncMatchTargets: + g: TcGlobals -> + matchInfo: DebugPointAtBinding * range * DecisionTree * DecisionTreeTarget array * range -> + mapTarget: (int -> Expr -> Expr option) -> + Expr option + val TryGetRuntimeAsyncReturn: g: TcGlobals -> expr: Expr -> RuntimeAsyncReturnInfo option val (|RuntimeAsyncReturnFunction|_|): g: TcGlobals -> expr: Expr -> (ValRef * ValUseFlag * range) voption From da6335753076a5641679911244f0448e6d22a1f3 Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:41:52 +0200 Subject: [PATCH 27/30] Prototype Core-owned runtime-async outlining with context handoff --- docs/runtime-async.md | 31 +++ src/Compiler/Driver/OptimizeInputs.fs | 2 +- src/Compiler/Optimize/LowerRuntimeAsync.fs | 221 +++++++++++++-- src/Compiler/Optimize/LowerRuntimeAsync.fsi | 8 +- src/Compiler/TypedTree/TcGlobals.fs | 6 + src/Compiler/TypedTree/TcGlobals.fsi | 2 + src/FSharp.Core/resumable.fs | 65 +++++ src/FSharp.Core/resumable.fsi | 28 ++ .../Language/RuntimeAsyncTests.fs | 256 ++++++++++++++++++ 9 files changed, 601 insertions(+), 18 deletions(-) diff --git a/docs/runtime-async.md b/docs/runtime-async.md index 282b287308e..32bd5594981 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -226,6 +226,37 @@ The prepared rewrite rebuilds expressions only after the entire construction has been accepted, so eligibility and rewriting cannot disagree about supported construction shapes. +### Opt-in outlining experiment + +`FSHARP_RUNTIME_ASYNC_OUTLINING_PROTOTYPE=1` replaces residual callback +inlining with outlining. The default remains the lowering described above. +Construction effects and branch selection stay at the original binding; +the selected closure is shared across invocations. Captured mutable locals +retain their identities and are handled by ordinary closure conversion, not +copied into and out of each fragment. + +The `net10.0` FSharp.Core asset supplies an inline +`StateMachineHelpers.__runtimeAsyncOutline` template. It wraps a callback +in a `__runtimeAsyncReturnValueTask` lambda, captures success or +`ExceptionDispatchInfo` on failure, and constructs a +`RuntimeAsyncFragmentResult` holding the final execution context, +synchronization context, and flow-suppression state. Lowering expands this +template through the existing expression optimizer rather than generating +the exception and context protocol itself. + +At each invocation the compiler constructs a `RuntimeAsyncFragmentAwaiter`, +uses the runtime's ordinary `AwaitAwaiter` entry point, then calls its +synchronous `GetResult`. Core restores the captured contexts before +returning the value or rethrowing. Restoration therefore executes in the +parent, not in another async method whose return would undo it. + +This preserves final context values but is not transparent: returning from +the child and reinstalling its context produces additional `AsyncLocal` +context-change notifications. It also changes closure allocation and +debugger-local representation. The experiment is not the default design. + +### Body preparation + Second, every return-marker body is prepared once after callbacks are inlined, innermost first: locals that cannot be preserved across a suspension are reported (once per range across the compilation), and suspending exception diff --git a/src/Compiler/Driver/OptimizeInputs.fs b/src/Compiler/Driver/OptimizeInputs.fs index bdca940af92..6234c2e98e2 100644 --- a/src/Compiler/Driver/OptimizeInputs.fs +++ b/src/Compiler/Driver/OptimizeInputs.fs @@ -408,7 +408,7 @@ let ApplyAllOptimizations }: PhaseInputs) : PhaseRes = let file = - LowerRuntimeAsync.TransformImplFile tcGlobals runtimeAsyncReportedRanges file + LowerRuntimeAsync.TransformImplFile tcGlobals importMap prevPhase.FirstLoopRes.OptDuringCodeGen runtimeAsyncReportedRanges file file, prevPhase diff --git a/src/Compiler/Optimize/LowerRuntimeAsync.fs b/src/Compiler/Optimize/LowerRuntimeAsync.fs index 254f6849027..518465fa2d7 100644 --- a/src/Compiler/Optimize/LowerRuntimeAsync.fs +++ b/src/Compiler/Optimize/LowerRuntimeAsync.fs @@ -10,8 +10,12 @@ open Internal.Utilities.Library open Internal.Utilities.Library.Extras open FSharp.Compiler +open FSharp.Compiler.AbstractIL.IL +open FSharp.Compiler.AccessibilityLogic open FSharp.Compiler.DiagnosticsLogger open FSharp.Compiler.Features +open FSharp.Compiler.InfoReader +open FSharp.Compiler.MethodCalls open FSharp.Compiler.RuntimeAsync open FSharp.Compiler.RuntimeAsyncAnalysis open FSharp.Compiler.RuntimeAsyncExceptionRewrite @@ -269,21 +273,198 @@ let private mkCallbackDispatch (g: TcGlobals) (stackGuard: StackGuard) (callback mkCompGenLet m argVal arg (dispatch 0 callback.Branches) +let rec private tryDelegateSignature g expr = + match expr with + | NewDelegateExpr g (_, [ parameter ], body, _, _) -> Some(parameter.Type, tyOfExpr g body) + | RuntimeAsyncDebugWrapper rest + | Expr.Sequential(_, rest, NormalSeq, _) + | Expr.Let(_, rest, _, _) -> tryDelegateSignature g rest + | Expr.Match(_, _, _, targets, _, _) when targets.Length > 0 -> + let (TTarget(_, body, _)) = targets[0] + tryDelegateSignature g body + | _ -> None + +let private outlineCallback (g: TcGlobals) amap optimizeExpr (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext expr = + let stackGuard = StackGuard("OutlineRuntimeAsyncCallbackPrototype") + let freeVarOptions = CollectLocalsWithStackGuard() + + let rec outlineBranches resultTy expr = + stackGuard.Guard(fun () -> + match expr with + | Expr.Lambda(_, None, None, [ parameter ], body, m, _) + | NewDelegateExpr g (_, [ parameter ], body, m, _) -> + let helper = g.cgh__runtimeAsyncOutline_vref + let callback = mkLambda m parameter (body, resultTy) + + let outlined = + primMkApp (exprForValRef m helper, helper.Type) [ parameter.Type; resultTy ] [ callback ] m + |> optimizeExpr true + + Some outlined + | RuntimeAsyncDebugWrapper body -> + outlineBranches resultTy body + |> Option.map (RebuildRuntimeAsyncDebugWrapper expr) + | Expr.Sequential(first, rest, NormalSeq, m) -> + outlineBranches resultTy rest + |> Option.map (fun rest -> Expr.Sequential(first, rest, NormalSeq, m)) + | Expr.Let(binding, rest, m, _) -> outlineBranches resultTy rest |> Option.map (mkLetBind m binding) + | Expr.Match(point, matchRange, tree, targets, m, _) -> + TryMapRuntimeAsyncMatchTargets g (point, matchRange, tree, targets, m) (fun _ -> outlineBranches resultTy) + | _ -> None) + + let rec outline expr = + stackGuard.Guard(fun () -> + match expr with + | Expr.Let(TBind(callback, construction, point), continuation, m, _) when + callback.InlineIfLambda && analyzer.ContainsSuspension construction + -> + let keep construction = + mkLetBind m (TBind(callback, construction, point)) (outline continuation) + + let shape = + match tryDestFunTy g callback.Type with + | ValueSome(argTy, resultTy) -> Some(argTy, resultTy, false) + | ValueNone when isFSharpDelegateTy g callback.Type -> + tryDelegateSignature g construction + |> Option.map (fun (argTy, resultTy) -> argTy, resultTy, true) + | _ -> None + + match shape with + | Some(argTy, resultTy, isDelegate) when + (runtimeAsyncContext || (TryGetRuntimeAsyncReturn g continuation).IsSome) + && not (isByrefLikeTy g m argTy || isByrefLikeTy g m resultTy) + -> + let canCapture = + (freeInExpr freeVarOptions construction).FreeLocals + |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) + + match + if canCapture then + tryAnalyzeCallbackUses g callback isDelegate continuation + else + ValueNone + with + | ValueNone -> keep construction + | ValueSome _ -> + let construction = outline construction + + match outlineBranches resultTy construction with + | None -> keep construction + | Some construction -> + let callbackTy = tyOfExpr g construction + let outlined, outlinedExpr = mkCompGenLocal m "runtimeAsyncFragment" callbackTy + + let continuation = + RewriteExpr + { + PreIntercept = + Some(fun rewrite expression -> + match tryCallbackInvocation g callback isDelegate expression with + | ValueSome(struct (arg, callRange)) -> + let invocation = + mkApps g ((outlinedExpr, callbackTy), [], [ rewrite arg ], callRange) + + let awaiterTy = mkWoNullAppTy g.runtimeAsyncFragmentAwaiter_tcref [ resultTy ] + let infoReader = InfoReader(g, amap) + + let constructor = + match GetIntrinsicConstructorInfosOfType infoReader callRange awaiterTy with + | [ constructor ] -> constructor + | _ -> + error ( + InternalError( + "runtime-async fragment awaiter constructor not found", + callRange + ) + ) + + let awaiter, awaiterExpr = + mkCompGenLocal callRange "runtimeAsyncFragmentAwaiter" awaiterTy + + let createAwaiter = + MakeMethInfoCall amap callRange constructor [] [ invocation ] None + + let awaitRef = + mkILMethRef ( + g.FindSysILTypeRef "System.Runtime.CompilerServices.AsyncHelpers", + ILCallingConv.Static, + "AwaitAwaiter", + 1, + [ mkILTyvarTy 0us ], + ILType.Void + ) + + let suspend = + Expr.Op( + TOp.ILCall( + false, + false, + false, + false, + NormalValUse, + false, + false, + awaitRef, + [], + [ awaiterTy ], + [] + ), + [], + [ awaiterExpr ], + callRange + ) + + let getResult = + match + TryFindIntrinsicMethInfo + infoReader + callRange + AccessorDomain.AccessibleFromEverywhere + "GetResult" + awaiterTy + with + | [ methodInfo ] -> methodInfo + | _ -> + error ( + InternalError( + "runtime-async fragment awaiter GetResult not found", + callRange + ) + ) + + let wrap, address, _, _ = + mkExprAddrOfExpr g true false NeverMutates awaiterExpr None callRange + + let result = MakeMethInfoCall amap callRange getResult [] [ address ] None |> wrap + + Some( + mkCompGenLet + callRange + awaiter + createAwaiter + (mkCompGenSequential callRange suspend result) + ) + | ValueNone -> None) + PreInterceptBinding = None + PostTransform = (fun _ -> None) + RewriteQuotations = false + StackGuard = stackGuard + } + continuation + + mkLet point m outlined construction (outline continuation) + | _ -> keep construction + | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (outline continuation) + | RuntimeAsyncDebugWrapper body -> RebuildRuntimeAsyncDebugWrapper expr (outline body) + | Expr.Sequential(first, rest, NormalSeq, m) -> Expr.Sequential(first, outline rest, NormalSeq, m) + | _ -> expr) + + outline expr + let private inlineCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext (expr: Expr) = let stackGuard = StackGuard("InlineRuntimeAsyncCallback") let freeVarOptions = CollectLocalsWithStackGuard() - let rec tryDelegateSignature expr = - match expr with - | NewDelegateExpr g (_, [ parameter ], body, _, _) -> Some(parameter.Type, tyOfExpr g body) - | RuntimeAsyncDebugWrapper rest - | Expr.Sequential(_, rest, NormalSeq, _) - | Expr.Let(_, rest, _, _) -> tryDelegateSignature rest - | Expr.Match(_, _, _, targets, _, _) when targets.Length > 0 -> - let (TTarget(_, body, _)) = targets[0] - tryDelegateSignature body - | _ -> None - let isTrivialValue = function | Expr.Const _ -> true @@ -371,7 +552,7 @@ let private inlineCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runti match tryDestFunTy g callback.Type with | ValueSome(argTy, resultTy) -> Some(argTy, resultTy, false) | ValueNone when isFSharpDelegateTy g callback.Type -> - tryDelegateSignature construction + tryDelegateSignature g construction |> Option.map (fun (argTy, resultTy) -> argTy, resultTy, true) | _ -> None @@ -478,10 +659,13 @@ let private containsRuntimeAsyncEntry g implFile = /// Inlines suspending InlineIfLambda callbacks into the enclosing method. `inContext` is true inside a runtime-async body or /// sequence recipe, including nested lambdas and delegates, but not object-expression methods, which are /// compiled as ordinary methods. -let private inlineCallbacks g implFile = +let private inlineCallbacks g amap optimizeExpr implFile = let analyzer = RuntimeAsyncAnalyzer g let stackGuard = StackGuard("InlineRuntimeAsyncCallbacks") + let outliningPrototype = + System.Environment.GetEnvironmentVariable("FSHARP_RUNTIME_ASYNC_OUTLINING_PROTOTYPE") = "1" + let rec leadsToRuntimeAsyncReturn expr = match expr with | Expr.Let(_, rest, _, _) @@ -529,7 +713,12 @@ let private inlineCallbacks g implFile = and postTransform inContext expr = match expr with | Expr.Let(TBind(callback, construction, _), _, _, _) when callback.InlineIfLambda && analyzer.ContainsSuspension construction -> - Some(inlineCallback g analyzer inContext expr) + Some( + if outliningPrototype then + outlineCallback g amap optimizeExpr analyzer inContext expr + else + inlineCallback g analyzer inContext expr + ) | _ -> None RewriteImplFile (rewriter false) implFile @@ -558,12 +747,12 @@ let private prepareBodies g (reportedRanges: ConcurrentDictionary) } implFile -let TransformImplFile (g: TcGlobals) reportedRanges (implFile: CheckedImplFile) = +let TransformImplFile (g: TcGlobals) amap optimizeExpr reportedRanges (implFile: CheckedImplFile) = if containsRuntimeAsyncEntry g implFile then // Bodies inlined from an assembly compiled with the feature are still prepared. let implFile = if g.langVersion.SupportsFeature LanguageFeature.RuntimeAsync then - inlineCallbacks g implFile + inlineCallbacks g amap optimizeExpr implFile else implFile diff --git a/src/Compiler/Optimize/LowerRuntimeAsync.fsi b/src/Compiler/Optimize/LowerRuntimeAsync.fsi index abd6adff8f4..95c5f84af2f 100644 --- a/src/Compiler/Optimize/LowerRuntimeAsync.fsi +++ b/src/Compiler/Optimize/LowerRuntimeAsync.fsi @@ -13,9 +13,15 @@ module internal FSharp.Compiler.LowerRuntimeAsync open System.Collections.Concurrent +open FSharp.Compiler.Import open FSharp.Compiler.TcGlobals open FSharp.Compiler.Text open FSharp.Compiler.TypedTree val TransformImplFile: - g: TcGlobals -> reportedRanges: ConcurrentDictionary -> implFile: CheckedImplFile -> CheckedImplFile + g: TcGlobals -> + amap: ImportMap -> + optimizeExpr: (bool -> Expr -> Expr) -> + reportedRanges: ConcurrentDictionary -> + implFile: CheckedImplFile -> + CheckedImplFile diff --git a/src/Compiler/TypedTree/TcGlobals.fs b/src/Compiler/TypedTree/TcGlobals.fs index adf0d4a7ea9..4af60cbc320 100644 --- a/src/Compiler/TypedTree/TcGlobals.fs +++ b/src/Compiler/TypedTree/TcGlobals.fs @@ -918,6 +918,10 @@ type TcGlobals( let v_cgh__runtimeAsyncReturnValueTaskUnit_info = makeIntrinsicValRef(fslib_MFStateMachineHelpers_nleref, "__runtimeAsyncReturnValueTaskUnit" , None , None , [], ([[v_unit_ty]], mkNonGenericTy v_valueTask_nonGeneric_tcr)) // handled specially by the checker let v_cgh__runtimeAsyncSequence_info = makeIntrinsicValRef(fslib_MFStateMachineHelpers_nleref, "__runtimeAsyncSequence", None, None, [vara], ([[v_unit_ty --> mkSeqTy varaTy]], TType_app(v_IAsyncEnumerable_tcr, [varaTy], v_knownWithoutNull))) let v_cgh__runtimeAsyncSequenceCancellationToken_info = makeIntrinsicValRef(fslib_MFStateMachineHelpers_nleref, "__runtimeAsyncSequenceCancellationToken", None, None, [], ([[v_unit_ty]], v_system_CancellationToken_ty)) + let v_runtimeAsyncFragmentResult_tcr = mk_MFCompilerServices_tcref fslibCcu "RuntimeAsyncFragmentResult`1" + let v_runtimeAsyncFragmentResult_ty = TType_app(v_runtimeAsyncFragmentResult_tcr, [varbTy], v_knownWithoutNull) + let v_runtimeAsyncFragmentAwaiter_tcr = mk_MFCompilerServices_tcref fslibCcu "RuntimeAsyncFragmentAwaiter`1" + let v_cgh__runtimeAsyncOutline_info = makeIntrinsicValRef(fslib_MFStateMachineHelpers_nleref, "__runtimeAsyncOutline", None, None, [vara;varb], ([[varaTy --> varbTy]], varaTy --> TType_app(v_valueTask_tcr, [v_runtimeAsyncFragmentResult_ty], v_knownWithoutNull))) let v_seq_to_array_info = makeIntrinsicValRef(fslib_MFSeqModule_nleref, "toArray" , None , Some "ToArray", [varb], ([[mkSeqTy varbTy]], mkArrayType 1 varbTy)) let v_seq_to_list_info = makeIntrinsicValRef(fslib_MFSeqModule_nleref, "toList" , None , Some "ToList" , [varb], ([[mkSeqTy varbTy]], mkListTy varbTy)) let v_seq_map_info = makeIntrinsicValRef(fslib_MFSeqModule_nleref, "map" , None , Some "Map" , [vara;varb], ([[varaTy --> varbTy]; [mkSeqTy varaTy]], mkSeqTy varbTy)) @@ -1878,6 +1882,8 @@ type TcGlobals( member val cgh__runtimeAsyncReturnValueTaskUnit_vref = ValRefForIntrinsic v_cgh__runtimeAsyncReturnValueTaskUnit_info member val cgh__runtimeAsyncSequence_vref = ValRefForIntrinsic v_cgh__runtimeAsyncSequence_info member val cgh__runtimeAsyncSequenceCancellationToken_vref = ValRefForIntrinsic v_cgh__runtimeAsyncSequenceCancellationToken_info + member val runtimeAsyncFragmentAwaiter_tcref = v_runtimeAsyncFragmentAwaiter_tcr + member val cgh__runtimeAsyncOutline_vref = ValRefForIntrinsic v_cgh__runtimeAsyncOutline_info member val cgh__useResumableCode_vref = ValRefForIntrinsic v_cgh__useResumableCode_info member val cgh__debugPoint_vref = ValRefForIntrinsic v_cgh__debugPoint_info member val cgh__resumeAt_vref = ValRefForIntrinsic v_cgh__resumeAt_info diff --git a/src/Compiler/TypedTree/TcGlobals.fsi b/src/Compiler/TypedTree/TcGlobals.fsi index 2964f5f20ed..32ad089ee23 100644 --- a/src/Compiler/TypedTree/TcGlobals.fsi +++ b/src/Compiler/TypedTree/TcGlobals.fsi @@ -473,6 +473,8 @@ type internal TcGlobals = member cgh__runtimeAsyncSequence_vref: TypedTree.ValRef member cgh__runtimeAsyncSequenceCancellationToken_vref: TypedTree.ValRef + member runtimeAsyncFragmentAwaiter_tcref: TypedTree.EntityRef + member cgh__runtimeAsyncOutline_vref: TypedTree.ValRef member cgh__useResumableCode_vref: TypedTree.ValRef diff --git a/src/FSharp.Core/resumable.fs b/src/FSharp.Core/resumable.fs index 439230ff5a0..e148eb833e1 100644 --- a/src/FSharp.Core/resumable.fs +++ b/src/FSharp.Core/resumable.fs @@ -76,6 +76,57 @@ type SetStateMachineMethodImpl<'Data> = delegate of byref = delegate of byref> -> 'Result +#if NET +[] +type RuntimeAsyncFragmentResult<'T>(result: Result<'T, System.Runtime.ExceptionServices.ExceptionDispatchInfo>) = + + let flowSuppressed = ExecutionContext.IsFlowSuppressed() + + let context = + if flowSuppressed then + ExecutionContext.RestoreFlow() + + try + ExecutionContext.Capture() + finally + ExecutionContext.SuppressFlow() |> ignore + else + ExecutionContext.Capture() + + let synchronizationContext = SynchronizationContext.Current + + member internal _.Restore() = + ExecutionContext.Restore(context) + SynchronizationContext.SetSynchronizationContext(synchronizationContext) + + if flowSuppressed then + ExecutionContext.SuppressFlow() |> ignore + + match result with + | Ok value -> value + | Error error -> + error.Throw() + Unchecked.defaultof<'T> + +[] +type RuntimeAsyncFragmentAwaiter<'T> = + val private Awaiter: ValueTaskAwaiter> + + new(fragment: ValueTask>) = { Awaiter = fragment.GetAwaiter() } + + member this.IsCompleted = this.Awaiter.IsCompleted + + member this.GetResult() = + this.Awaiter.GetResult().Restore() + + interface ICriticalNotifyCompletion with + member this.OnCompleted(continuation) = + this.Awaiter.OnCompleted(continuation) + + member this.UnsafeOnCompleted(continuation) = + this.Awaiter.UnsafeOnCompleted(continuation) +#endif + [] module StateMachineHelpers = @@ -126,6 +177,20 @@ module StateMachineHelpers = failwith "__runtimeAsyncReturnValueTask is a compiler intrinsic and should only be used in runtime-async method bodies" + let inline __runtimeAsyncOutline + ([] callback: 'Arg -> 'T) + : 'Arg -> ValueTask> = + fun argument -> + __runtimeAsyncReturnValueTask ( + let result = + try + Ok(callback argument) + with error -> + Error(System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(error)) + + RuntimeAsyncFragmentResult(result) + ) + [] let __runtimeAsyncReturnUnit () : Task = failwith diff --git a/src/FSharp.Core/resumable.fsi b/src/FSharp.Core/resumable.fsi index 0082b32f3b8..8323cec9d5c 100644 --- a/src/FSharp.Core/resumable.fsi +++ b/src/FSharp.Core/resumable.fsi @@ -8,6 +8,30 @@ open System open System.Runtime.CompilerServices open System.Threading +#if NET +/// Carries an outlined runtime-async fragment's outcome and final ambient contexts. +[] +type RuntimeAsyncFragmentResult<'T> = + /// Captures an outcome and the current execution and synchronization contexts. + new: result: Result<'T, System.Runtime.ExceptionServices.ExceptionDispatchInfo> -> RuntimeAsyncFragmentResult<'T> + +/// Awaits an outlined fragment and restores its final contexts when retrieving the outcome. +[] +type RuntimeAsyncFragmentAwaiter<'T> = + val private Awaiter: ValueTaskAwaiter> + + /// Creates an awaiter for an outlined fragment. + new: fragment: System.Threading.Tasks.ValueTask> -> RuntimeAsyncFragmentAwaiter<'T> + + /// Indicates whether the fragment has completed. + member IsCompleted: bool + + /// Restores the fragment's final contexts, then returns its value or rethrows its exception. + member GetResult: unit -> 'T + + interface ICriticalNotifyCompletion +#endif + /// Acts as a template for struct state machines introduced by __stateMachine, and also as a reflective implementation [] type ResumableStateMachine<'Data> = @@ -204,6 +228,10 @@ module StateMachineHelpers = [] val __runtimeAsyncReturnValueTask : 'T -> System.Threading.Tasks.ValueTask<'T> + /// Outlines a callback as a runtime-async fragment that captures its outcome and final ambient contexts. + val inline __runtimeAsyncOutline: + [] callback: ('Arg -> 'T) -> ('Arg -> System.Threading.Tasks.ValueTask>) + [] val __runtimeAsyncReturnUnit : unit -> System.Threading.Tasks.Task diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index 4cac3dde524..d8ca1d08d8c 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -2091,6 +2091,262 @@ let main _ = |> compileExeAndRun |> shouldSucceed + [] + [] + [] + [] + [] + let ``runtime async Core outlining helper preserves context and shared mutation`` (optimize: bool, pending: bool) = + FSharp $""" +module RuntimeAsyncCoreOutline +open System.Threading +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let context = AsyncLocal() + +[] +let run (gate: Task) = + StateMachineHelpers.__runtimeAsyncReturn( + context.Value <- "outer" + let mutable count = 0 + let observe () = count + let fragment = + StateMachineHelpers.__runtimeAsyncOutline (fun increment -> + AsyncHelpers.Await gate + count <- count + increment + context.Value <- "inner" + count) + let first = RuntimeAsyncFragmentAwaiter(fragment 2) + AsyncHelpers.AwaitAwaiter first + if first.GetResult() <> 2 || observe() <> 2 || context.Value <> "inner" then + failwith "First fragment lost context or shared state" + let second = RuntimeAsyncFragmentAwaiter(fragment 3) + AsyncHelpers.AwaitAwaiter second + if second.GetResult() <> 5 || observe() <> 5 || context.Value <> "inner" then + failwith "Repeated fragment lost context or shared state" + count) + +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + if {if pending then "false" else "true"} then gate.SetResult(()) + let result = run gate.Task + if not (isNull context.Value) then failwith "Context leaked to caller" + gate.TrySetResult(()) |> ignore + if result.GetAwaiter().GetResult() <> 5 then failwith "Incorrect result" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + [] + [] + [] + [] + [] + let ``runtime async callback preserves final context on success and failure`` (optimize: bool, pending: bool) = + let result = + FSharp $""" +module RuntimeAsyncContextHandoff +open System +open System.Threading +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let context = AsyncLocal() +let synchronizationContext = SynchronizationContext() +let expectedError = InvalidOperationException("fragment") + +let inline invoke ([] callback: unit -> int) = + let seen = callback() + seen + context.Value.Length + +[] +let run (gate: Task) (next: Task) flag fail suppress = + StateMachineHelpers.__runtimeAsyncReturn( + context.Value <- "outer" + let value = + try + invoke ( + if flag then + let captured = context.Value + ":" + fun () -> + AsyncHelpers.Await gate + if captured <> "outer:" then failwith "Captured state changed" + context.Value <- "inner" + SynchronizationContext.SetSynchronizationContext(synchronizationContext) + if suppress then ExecutionContext.SuppressFlow() |> ignore + if fail then raise expectedError + 36 + else + fun () -> 0) + with error when obj.ReferenceEquals(error, expectedError) && context.Value = "inner" -> + 42 + if context.Value <> "inner" then failwith "Fragment context was not restored" + if not (obj.ReferenceEquals(SynchronizationContext.Current, synchronizationContext)) then + failwith "Synchronization context was not restored" + if ExecutionContext.IsFlowSuppressed() <> suppress then failwith "Suppression state was not restored" + if suppress then ExecutionContext.RestoreFlow() + SynchronizationContext.SetSynchronizationContext(null) + AsyncHelpers.Await next + if context.Value <> "inner" then failwith "Context was lost at a subsequent await" + value) + +[] +let main _ = + for fail in [false; true] do + for suppress in [false; true] do + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + let next = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + if {if pending then "false" else "true"} then gate.SetResult(()) + let result = run gate.Task next.Task true fail suppress + if result.IsCompleted then failwith "Expected a pending result" + if not (isNull context.Value) || not (isNull SynchronizationContext.Current) then + failwith "Fragment context leaked to the outer caller" + gate.TrySetResult(()) |> ignore + next.SetResult(()) + let expected = if fail then 42 else 41 + if result.GetAwaiter().GetResult() <> expected then failwith "Incorrect fragment outcome" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + if System.Environment.GetEnvironmentVariable("FSHARP_RUNTIME_ASYNC_OUTLINING_PROTOTYPE") = "1" then + result + |> verifyILContains [ + "call instance !0 valuetype [FSharp.Core]Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1::GetResult()" + ] + |> ignore + + [] + [] + [] + [] + [] + let ``runtime async callback does not add context change notifications`` (optimize: bool, pending: bool) = + let result = + FSharp $""" +module RuntimeAsyncContextNotifications +open System.Collections.Generic +open System.Threading +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let trace = ResizeArray() +let mutable recording = false +let context = + AsyncLocal(fun change -> + if recording then + trace.Add($"{{change.PreviousValue}}->{{change.CurrentValue}}:{{change.ThreadContextChanged}}")) + +let inline invoke ([] callback: unit -> int) = + let seen = callback() + seen + context.Value.Length + +[] +let run (gate: Task) flag = + StateMachineHelpers.__runtimeAsyncReturn( + context.Value <- "outer" + let value = + invoke ( + if flag then + let captured = context.Value + ":" + fun () -> + AsyncHelpers.Await gate + if captured <> "outer:" then failwith "Captured state changed" + recording <- true + context.Value <- "inner" + 36 + else + fun () -> 0) + recording <- false + if context.Value <> "inner" then failwith "Incorrect final context" + if trace.ToArray() <> [| "outer->inner:False" |] then + failwithf "Additional context notifications: %%A" (trace.ToArray()) + value) + +[] +let main _ = + let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + if {if pending then "false" else "true"} then gate.SetResult(()) + let result = run gate.Task true + gate.TrySetResult(()) |> ignore + if result.GetAwaiter().GetResult() <> 41 then failwith "Incorrect result" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + + if System.Environment.GetEnvironmentVariable("FSHARP_RUNTIME_ASYNC_OUTLINING_PROTOTYPE") = "1" then + result + |> verifyILContains [ + "call instance !0 valuetype [FSharp.Core]Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1::GetResult()" + ] + |> ignore + + [] + [] + [] + let ``runtime async callback preserves context exception filter ordering`` (optimize: bool) = + FSharp """ +module RuntimeAsyncContextExceptionFilter +open System.Threading.Tasks +open System.Runtime.CompilerServices +open Microsoft.FSharp.Core.CompilerServices + +let mutable finallyRan = false +let mutable filterSawFinally = false +let inline invoke ([] callback: unit -> int) = + let value = callback() + value + 1 + +[] +let run flag = + StateMachineHelpers.__runtimeAsyncReturn( + try + invoke ( + if flag then + let captured = System.DateTime.UtcNow.Ticks + fun () -> + AsyncHelpers.Await Task.CompletedTask + try + failwithf "fragment %d" captured + finally + finallyRan <- true + else + fun () -> 0) + with error when ( + filterSawFinally <- finallyRan + error.Message.StartsWith("fragment", System.StringComparison.Ordinal)) -> + 42) + +[] +let main _ = + if (run true).GetAwaiter().GetResult() <> 42 then failwith "Incorrect exception outcome" + if not finallyRan then failwith "Fragment finally did not run" + if not filterSawFinally then failwith "Parent exception filter ran before the fragment finally" + 0 +""" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + |> compileExeAndRun + |> shouldSucceed + [] [] [] From 7e6974ab3dd8643455b682c5f5f16a026b44b06a Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:21:20 +0200 Subject: [PATCH 28/30] Replace residual runtime-async callback inlining with Core-owned outlining --- .../.FSharp.Compiler.Service/11.0.200.md | 2 +- docs/release-notes/.FSharp.Core/11.0.200.md | 2 + docs/runtime-async.md | 90 +-- src/Compiler/CodeGen/IlxGen.fs | 22 - src/Compiler/Driver/OptimizeInputs.fs | 2 +- src/Compiler/Optimize/LowerLocalMutables.fs | 4 - src/Compiler/Optimize/LowerRuntimeAsync.fs | 584 +++--------------- src/Compiler/Optimize/LowerRuntimeAsync.fsi | 4 +- src/Compiler/Optimize/Optimizer.fs | 2 - src/Compiler/Optimize/RuntimeAsyncAnalysis.fs | 16 +- src/Compiler/TypedTree/RuntimeAsync.fs | 12 - src/Compiler/TypedTree/RuntimeAsync.fsi | 5 - src/Compiler/TypedTree/TypedTree.fs | 4 - src/Compiler/TypedTree/TypedTree.fsi | 3 - .../TypedTree/TypedTreeOps.Remapping.fs | 6 +- src/Compiler/TypedTree/TypedTreePickle.fs | 3 +- .../Language/RuntimeAsyncTests.fs | 86 +-- .../FSharp.Core.SurfaceArea.netcore.debug.bsl | 6 + ...Sharp.Core.SurfaceArea.netcore.release.bsl | 6 + 19 files changed, 180 insertions(+), 679 deletions(-) diff --git a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md index 2b43d0e96ac..c39914fceb1 100644 --- a/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md +++ b/docs/release-notes/.FSharp.Compiler.Service/11.0.200.md @@ -7,7 +7,7 @@ * Split Linux CoreCLR CI into two parallel test batches to reduce test turnaround time. ([PR #20587](https://github.com/dotnet/fsharp/pull/20587)) * Fix `NativePtr.stackalloc` used inside the `with` handler, filter or `finally` block of a `try` expression producing an assembly that throws `InvalidProgramException` at method load. Direct or mandatory-inline allocations are now reported at compile time as error FS3924; calls to non-inline allocation helpers, including handler-local helpers, remain legal and retain their method boundary during inlining and let-binding elimination. ([Issue #20295](https://github.com/dotnet/fsharp/issues/20295), [PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * `FsiEvaluationSession.EvalInteraction` and `EvalInteractionNonThrowing` evaluate every `;;`-separated interaction in the text they are given, as their documentation has always said, instead of silently discarding everything after the first. ([PR #20396](https://github.com/dotnet/fsharp/pull/20396)) -* Preserve stateful conditional callbacks in runtime-async inline expansions: a suspending function or delegate selected by a branch is constructed once, recording the selected branch and immutable captures while keeping mutable captures shared with other closures. Each invocation inlines the selected body into the enclosing runtime-async method, so construction effects are not duplicated and `AsyncLocal` changes made by the callback remain visible. Inline single-use delegates through effect-free conditional construction, including nested merged sources and async-sequence consumers; also lower conditional receivers left directly on `Invoke` after optimization, while rejecting suspensions in unmarked delegate methods. Callback inlining and runtime-async body preparation now run in a dedicated lowering pass after optimization, so awaits inlined into exception handlers are rewritten. Shared debug-wrapper handling preserves callback statement points and captured mutable locals' lexical scopes during construction and invocation without adding reference-cell allocations. Codegen shares marker preparation and closure validation. Stack guards protect callback reconstruction, capture analysis, and suspension-flow analysis for deeply nested bodies. Branch sizes and captures are cached, and callback uses and the existing copy budget are checked before expanding invocations. Single-use delegates share one construction analysis for eligibility and invocation rewriting. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) +* Preserve stateful conditional callbacks in runtime-async inline expansions by outlining residual suspending functions and delegates through a FSharp.Core template. Construction runs once, mutable captures remain shared, and each invocation awaits the selected fragment and restores its final ambient contexts before continuing or rethrowing. This removes callback-body copying and its size budget. The additional async boundary produces context-change notifications and represents captured locals as closure fields instead of parent-local debugger aliases. ([Issue #20577](https://github.com/dotnet/fsharp/issues/20577), [PR #20647](https://github.com/dotnet/fsharp/pull/20647)) * Prevent `InvalidProgramException` when runtime async awaits a `Task` upcast to `Task`, including through `ConfigureAwait`. ([Issue #20686](https://github.com/dotnet/fsharp/issues/20686), [PR #20726](https://github.com/dotnet/fsharp/pull/20726)) * Preserve constraint inference order when explicit generic arguments share a caller type parameter, avoiding spurious FS0001 errors with multiply implemented generic interfaces. ([PR #20491](https://github.com/dotnet/fsharp/pull/20491)) * Build parser diagnostic messages without runtime printf specialization or reflective function construction, so Native AOT hosts report indentation and invalid-character diagnostics instead of failing during formatting. Preserve `#r` reference tooltips while script project options remain alive, including after garbage collection or cache eviction. ([PR #20658](https://github.com/dotnet/fsharp/pull/20658)) diff --git a/docs/release-notes/.FSharp.Core/11.0.200.md b/docs/release-notes/.FSharp.Core/11.0.200.md index 3948a0f42b4..9658ee10c47 100644 --- a/docs/release-notes/.FSharp.Core/11.0.200.md +++ b/docs/release-notes/.FSharp.Core/11.0.200.md @@ -1,5 +1,7 @@ ### Added +* Add the runtime-async outlining template and a context-restoring fragment awaiter to the net10.0 asset. Fragment outcomes preserve final execution and synchronization contexts, flow-suppression state, and captured exceptions. ([PR #20647](https://github.com/dotnet/fsharp/pull/20647)) + ### Fixed ### Changed diff --git a/docs/runtime-async.md b/docs/runtime-async.md index 32bd5594981..ea313f6f070 100644 --- a/docs/runtime-async.md +++ b/docs/runtime-async.md @@ -166,30 +166,26 @@ still evaluated at the same point. `LowerRuntimeAsync.fs` runs once per file after the first optimization loop, so it sees the final inlined shape rather than an intermediate optimizer -state, and before `LowerLocalMutables`, so mutable locals it introduces that -closures capture are promoted to reference cells. It has two steps. +state, and before `LowerLocalMutables`, so captured mutable locals receive +the ordinary shared reference-cell representation. It has two steps. -Callback-use analysis, capture hoisting, and invocation rewriting live in +Callback-use analysis, outlining, and invocation rewriting live in `LowerRuntimeAsync.fs`. `RuntimeAsyncAnalysis.fs` supplies fragment detection, optimizer-side returned-closure reduction, and suspension-lifetime analysis. -Both transformations share debug-wrapper and match-target rebuilding helpers -from `TypedTree/RuntimeAsync.fs`. +Both transformations share match-target rebuilding from +`TypedTree/RuntimeAsync.fs`. First, if a compiler-owned `InlineIfLambda` function or delegate still contains a suspension, a fully rewritable, single-argument callback is -inlined into the enclosing method. The ordinary optimizer already inlines a +outlined as a separate runtime-async closure. The ordinary optimizer already inlines a callback whose construction ends in one lambda, including after `let` or effect prefixes and at repeated invocations. What remains is a construction -that selects a lambda by branching (`if`/`match`). It is defunctionalized: -the construction runs once, in place, and each lambda in tail position is -replaced by an assignment of its branch tag and of the construction locals it -captures to mutable locals of the enclosing method. Each invocation binds its -argument once and dispatches on the tag to a copy of the selected lambda body. -Construction effects happen once, captured state is shared across -invocations, and every `Await` stays in the runtime-async method, so -`ExecutionContext` changes such as `AsyncLocal` writes behave as in source -order. Only the specialized copy changes, not the exported inline definition -or source signature. This applies within a runtime-async body or sequence +that selects a lambda by branching (`if`/`match`). Construction effects and +branch selection stay at the original binding; each invocation calls the +selected closure and awaits its outcome before continuing. Callback bodies +are not copied into each invocation, so there is no callback-copy budget. +Only the specialized copy changes, not the exported inline definition or +source signature. This applies within a runtime-async body or sequence recipe, and to a callback bound immediately before a runtime-async body together with the callbacks its construction captures. Opaque consumers, escaping callbacks, unsupported callback shapes (including `try`/`with` in @@ -197,43 +193,21 @@ tail position), and unsafe byref or pinned captures are not rewritten; suspensions remaining in an ordinary method, such as a closure, are diagnosed with FS3918. -The ordinary optimizer can inline an `Invoke` on a constructed delegate. -For a residual `InlineIfLambda` delegate bound to a local, a single direct -invocation can also be inlined through simple, effect-free conditional -construction. Otherwise, directly rewritable invocations dispatch on the -branch tag. Inner callback bindings are processed before the outer delegate. +Inner callback bindings are processed before the outer delegate. If optimization leaves a construction expression directly as an `Invoke` receiver, it is first bound to a compiler-owned callback and lowered through the same path. Receiver construction still precedes argument evaluation. Effectful precomputations that capture a delegate's inputs are not moved to its invocation, so a pending operation is created once and repeated invokes -share captured state. A delegate that escapes or is consumed opaquely cannot -be converted. IlxGen checks each generated delegate `Invoke` as its own -method: an `Await` left there without a return marker produces FS3918, even -when the enclosing method is runtime-async. Exported inline definitions remain -unchanged. - -Each invocation copies every branch body, so code size grows with the number -of invocations times the number of branches. Branch sizes and captures are -analyzed once. Callback uses are analyzed separately before rewriting: -opaque consumers and quotations retaining the callback reject the candidate, -and invocations nested in arguments count towards the same -2000-expression-node copy budget. The total cost is checked before copying any -branch bodies. A rejected callback is left as a closure and a suspension in -it is reported as FS3918. Single-use delegate inlining checks use count, then -analyzes effect-free construction once to prepare its invocation rewrite. -The prepared rewrite rebuilds expressions only after the entire construction -has been accepted, so eligibility and rewriting cannot disagree about -supported construction shapes. - -### Opt-in outlining experiment - -`FSHARP_RUNTIME_ASYNC_OUTLINING_PROTOTYPE=1` replaces residual callback -inlining with outlining. The default remains the lowering described above. -Construction effects and branch selection stay at the original binding; -the selected closure is shared across invocations. Captured mutable locals +share captured state. Captured mutable locals retain their identities and are handled by ordinary closure conversion, not -copied into and out of each fragment. +copied into and out of each fragment. Callback uses are checked before +rewriting; opaque consumers and quotations retaining the callback reject the +candidate. IlxGen checks each generated closure or delegate `Invoke` as its +own method: an `Await` left there without a return marker produces FS3918, +even when the enclosing method is runtime-async. + +### Context handoff The `net10.0` FSharp.Core asset supplies an inline `StateMachineHelpers.__runtimeAsyncOutline` template. It wraps a callback @@ -245,19 +219,23 @@ template through the existing expression optimizer rather than generating the exception and context protocol itself. At each invocation the compiler constructs a `RuntimeAsyncFragmentAwaiter`, -uses the runtime's ordinary `AwaitAwaiter` entry point, then calls its -synchronous `GetResult`. Core restores the captured contexts before -returning the value or rethrowing. Restoration therefore executes in the +uses the runtime's ordinary `AwaitAwaiter` entry point if the fragment is +incomplete, then calls its synchronous `GetResult`. Completed fragments do +not introduce an additional suspension. Core restores the captured contexts +before returning the value or rethrowing. Restoration therefore executes in the parent, not in another async method whose return would undo it. This preserves final context values but is not transparent: returning from the child and reinstalling its context produces additional `AsyncLocal` -context-change notifications. It also changes closure allocation and -debugger-local representation. The experiment is not the default design. +context-change notifications. Unlike an inlined callback, an outlined +callback can allocate a closure and shared reference cells. Captured locals +are represented as closure fields at invocation rather than aliases to +locals in the parent method; their original construction scopes remain in +the PDB. ### Body preparation -Second, every return-marker body is prepared once after callbacks are inlined, +Second, every return-marker body is prepared once after callbacks are outlined, innermost first: locals that cannot be preserved across a suspension are reported (once per range across the compilation), and suspending exception handlers are rewritten. Preparing afterwards means an `Await` that callback @@ -272,10 +250,8 @@ call site. User continuation arguments keep their own ranges, and the marked method retains a call-site sequence point even when forced inlining reduces its intermediate closures. -Callback lowering shares recognition and rebuilding of transparent debug -points and lexical-scope annotations. Rebuilding retains the original range, -scope name, and storage reference; a lexical scope is not discarded when -reducing an application through its wrapper. +Callback lowering retains source debug points when rebuilding construction +wrappers. Outlined methods keep the callback body's statement points. Dead branches eliminated by optimization do not reach code generation and do not produce a suspension-outside-runtime-async diagnostic. diff --git a/src/Compiler/CodeGen/IlxGen.fs b/src/Compiler/CodeGen/IlxGen.fs index 65462af27e4..903c0e731f3 100644 --- a/src/Compiler/CodeGen/IlxGen.fs +++ b/src/Compiler/CodeGen/IlxGen.fs @@ -3353,28 +3353,6 @@ and GenExprAux (cenv: cenv) (cgbuf: CodeGenBuffer) eenv expr (sequel: sequel) = | Expr.LetRec(binds, body, m, _) -> GenLetRec cenv cgbuf eenv (binds, body, m) sequel - | Expr.Op(TOp.DebugLocalScope(vref, name), [], [ body ], m) -> - if cenv.options.generateDebugSymbols then - let _, endMark as scopeMarks = StartLocalScope "debugLocal" cgbuf - - match StorageForValRef m vref eenv with - | Local(index, _, _) -> cgbuf.AddLocalDebugRange(index, name, scopeMarks) - | _ when not cenv.options.localOptimizationsEnabled -> - if vref.IsMutable && not (Optimizer.IsKnownOnlyMutableBeforeUse vref) then - error (InternalError("debug local scope requires shared storage for a mutable capture", m)) - - // Escaping mutables are reference cells, so the alias copies their shared home. - let index = - cgbuf.AllocLocal([ (name, scopeMarks) ], GenTypeOfVal cenv eenv vref.Deref, false, false) - - GenGetVal cenv cgbuf eenv (vref, m) Continue - EmitSetLocal cgbuf index - | _ -> () - - GenExpr cenv cgbuf eenv body (EndLocalScope(sequel, endMark)) - else - GenExpr cenv cgbuf eenv body sequel - | Expr.Lambda _ | Expr.TyLambda _ -> GenLambda cenv cgbuf eenv false [] expr sequel diff --git a/src/Compiler/Driver/OptimizeInputs.fs b/src/Compiler/Driver/OptimizeInputs.fs index 6234c2e98e2..81a24d7e808 100644 --- a/src/Compiler/Driver/OptimizeInputs.fs +++ b/src/Compiler/Driver/OptimizeInputs.fs @@ -412,7 +412,7 @@ let ApplyAllOptimizations file, prevPhase - // Must precede lowerLocalMutables: inlined callbacks introduce mutable locals that closures can capture. + // Must precede lowerLocalMutables: outlined callbacks capture shared mutable locals. addPhase "lowerRuntimeAsync" lowerRuntimeAsync let lowerLocalMutables diff --git a/src/Compiler/Optimize/LowerLocalMutables.fs b/src/Compiler/Optimize/LowerLocalMutables.fs index 12c7883be96..37d3231b5c1 100644 --- a/src/Compiler/Optimize/LowerLocalMutables.fs +++ b/src/Compiler/Optimize/LowerLocalMutables.fs @@ -145,10 +145,6 @@ let DecideImplFile g amap implFile = let TransformExpr g (heapValMap: ValMap<_>) exprF expr = match expr with - | Expr.Op (TOp.DebugLocalScope (ValDeref(v), name), [], [body], m) when heapValMap.ContainsVal v -> - let nv, _ = heapValMap[v] - Some (Expr.Op (TOp.DebugLocalScope (mkLocalValRef nv, name), [], [exprF body], m)) - // Rewrite uses of mutable values | Expr.Val (ValDeref(v), _, m) when heapValMap.ContainsVal v -> diff --git a/src/Compiler/Optimize/LowerRuntimeAsync.fs b/src/Compiler/Optimize/LowerRuntimeAsync.fs index 518465fa2d7..01722bb84b3 100644 --- a/src/Compiler/Optimize/LowerRuntimeAsync.fs +++ b/src/Compiler/Optimize/LowerRuntimeAsync.fs @@ -3,7 +3,6 @@ module internal FSharp.Compiler.LowerRuntimeAsync open System.Collections.Concurrent -open System.Collections.Generic open Internal.Utilities.Collections open Internal.Utilities.Library @@ -44,248 +43,87 @@ let private tryCallbackInvocation (g: TcGlobals) (callback: Val) isDelegate expr ValueSome(struct (arg, m)) | _ -> ValueNone -let private tryAnalyzeCallbackUses g callback isDelegate continuation = +let private canRewriteCallbackUses g callback isDelegate continuation = let folder = { ExprFolder0 with exprIntercept = - fun recurse noIntercept uses expression -> - match uses with - | ValueNone -> ValueNone - | ValueSome count -> + fun recurse noIntercept valid expression -> + if not valid then + false + else match tryCallbackInvocation g callback isDelegate expression with - | ValueSome(struct (arg, _)) -> recurse (ValueSome(count + 1)) arg + | ValueSome(struct (arg, _)) -> recurse true arg | ValueNone -> match expression with - | Expr.Val(vref, _, _) when valEq callback vref.Deref -> ValueNone + | Expr.Val(vref, _, _) when valEq callback vref.Deref -> false | Expr.Quote(_, dataCell, _, _, _) -> if isQuotationUsing callback expression then - ValueNone + false else match dataCell.Value with | Some((_, _, args, _), (_, _, legacyArgs, _)) -> - List.fold recurse (List.fold recurse uses args) legacyArgs - | None -> uses - | _ -> noIntercept uses expression + List.fold recurse (List.fold recurse true args) legacyArgs + | None -> true + | _ -> noIntercept true expression } - FoldExpr folder (ValueSome 0) continuation - -type private CallbackBranch = - { - Parameter: Val - Body: Expr - Captures: Val list - NodeCount: int - } - -/// A callback construction whose branches select one of several lambdas. The construction runs once -/// and records the selected branch and the values it captures; each invocation dispatches on the branch. -type private CallbackBranches = - { - Construction: Expr - Tag: Val - Branches: CallbackBranch list - Captures: (Val * Val) list - } - -/// A captured mutable keeps a single shared home: its binding becomes an assignment to the hoisted local and -/// every other use is redirected to it, so writes made by the inlined callback stay visible to other closures. -/// Returns None if a captured mutable is not bound by a let in the construction. -let private tryShareMutableCallbackCaptures (g: TcGlobals) (mutableCaptures: (Val * Val) list) construction = - if mutableCaptures.IsEmpty then - Some construction - else - let hoistedOf = - ValMap.OfList(mutableCaptures |> List.map (fun (v, hoisted) -> v, mkLocalValRef hoisted)) - - let rewritten = HashSet() - - let rwenv = - { - PreIntercept = - Some(fun rewrite expression -> - match expression with - | Expr.Let(TBind(v, rhs, point), body, m, _) when hoistedOf.ContainsVal v -> - rewritten.Add v.Stamp |> ignore - let assignment = mkValSet m hoistedOf[v] (rewrite rhs) - - let assignment = - match point with - | DebugPointAtBinding.Yes point -> mkDebugPoint point assignment - | _ -> assignment - - let body = rewrite body - - let body = - if v.IsCompilerGenerated then - body - else - Expr.Op(TOp.DebugLocalScope(hoistedOf[v], v.LogicalName), [], [ body ], m) - - Some(mkCompGenSequential m assignment body) - | _ -> None) - PreInterceptBinding = None - PostTransform = (fun _ -> None) - RewriteQuotations = false - StackGuard = StackGuard("RewriteRuntimeAsyncMutableCaptures") - } + FoldExpr folder true continuation - let construction = RewriteExpr rwenv construction - - if rewritten.Count = mutableCaptures.Length then - Some(remapExpr g CloneAll { emptyRemap with valRemap = hoistedOf } construction) - else - None +let rec private tryDelegateSignature g expr = + match expr with + | NewDelegateExpr g (_, [ parameter ], body, _, _) -> Some(parameter.Type, tyOfExpr g body) + | Expr.DebugPoint(_, rest) + | Expr.Sequential(_, rest, NormalSeq, _) + | Expr.Let(_, rest, _, _) -> tryDelegateSignature g rest + | Expr.Match(_, _, _, targets, _, _) when targets.Length > 0 -> + let (TTarget(_, body, _)) = targets[0] + tryDelegateSignature g body + | _ -> None -let private exprNodeCount expr = - let folder = - { ExprFolder0 with - exprIntercept = fun _ noInterceptF count expr -> noInterceptF (count + 1) expr - } +let private awaitFragment (g: TcGlobals) amap resultTy invocation m = + let awaiterTy = mkWoNullAppTy g.runtimeAsyncFragmentAwaiter_tcref [ resultTy ] + let infoReader = InfoReader(g, amap) - FoldExpr folder 0 expr + let constructor = + match GetIntrinsicConstructorInfosOfType infoReader m awaiterTy with + | [ constructor ] -> constructor + | _ -> error (InternalError("runtime-async fragment awaiter constructor not found", m)) -/// Replaces each lambda in tail position of a callback construction with an assignment of its branch tag -/// and captured locals, or returns None if a tail is not a supported single-argument lambda. -let private tryDefunctionalizeCallback (g: TcGlobals) (stackGuard: StackGuard) m construction = - let freeVarOptions = CollectLocalsWithStackGuard() - let constructionFree = (freeInExpr freeVarOptions construction).FreeLocals - let tag, _ = mkMutableCompGenLocal m "runtimeAsyncCallbackTag" g.int32_ty - let branches = ResizeArray() - let captures = Dictionary() + let awaiter, awaiterExpr = mkCompGenLocal m "runtimeAsyncFragmentAwaiter" awaiterTy + let createAwaiter = MakeMethInfoCall amap m constructor [] [ invocation ] None - let rec defunctionalize expr = - stackGuard.Guard(fun () -> defunctionalizeCore expr) + let awaitRef = + mkILMethRef ( + g.FindSysILTypeRef "System.Runtime.CompilerServices.AsyncHelpers", + ILCallingConv.Static, + "AwaitAwaiter", + 1, + [ mkILTyvarTy 0us ], + ILType.Void + ) - and defunctionalizeCore expr = - match expr with - | Expr.Lambda(_, None, None, [ parameter ], body, m, _) - | NewDelegateExpr g (_, [ parameter ], body, m, _) -> - let captured = - (freeInExpr freeVarOptions body).FreeLocals - |> Zset.elements - |> List.filter (fun v -> not (valEq v parameter) && not (Zset.contains v constructionFree)) - - if - captured - |> List.exists (fun v -> v.IsPinning || isByrefTy g v.Type || isByrefLikeTy g m v.Type) - then - None - else - let assignments = - captured - |> List.map (fun v -> - let _, hoisted = - match captures.TryGetValue v.Stamp with - | true, capture -> capture - | _ -> - let hoisted = - if v.IsMutable then - let hoisted = Construct.NewModifiedVal id v - hoisted.SetIsCompilerGenerated true - hoisted - else - fst (mkMutableCompGenLocal m v.LogicalName v.Type) - - let capture = v, hoisted - captures[v.Stamp] <- capture - capture - - if v.IsMutable then - None - else - Some(mkValSet m (mkLocalValRef hoisted) (exprForVal m v))) - |> List.choose id - - let select = mkValSet m (mkLocalValRef tag) (mkInt32 g m branches.Count) - - branches.Add - { - Parameter = parameter - Body = body - Captures = captured - NodeCount = exprNodeCount body - } - - Some(List.foldBack (mkCompGenSequential m) assignments select) - | RuntimeAsyncDebugWrapper body -> defunctionalize body |> Option.map (RebuildRuntimeAsyncDebugWrapper expr) - | Expr.Sequential(first, rest, NormalSeq, m) -> - defunctionalize rest - |> Option.map (fun rest -> Expr.Sequential(first, rest, NormalSeq, m)) - | Expr.Let(binding, rest, m, _) -> defunctionalize rest |> Option.map (mkLetBind m binding) - | Expr.Match(point, matchRange, tree, targets, m, _) -> - TryMapRuntimeAsyncMatchTargets g (point, matchRange, tree, targets, m) (fun _ -> defunctionalize) - | _ -> None + let suspend = + Expr.Op(TOp.ILCall(false, false, false, false, NormalValUse, false, false, awaitRef, [], [ awaiterTy ], []), [], [ awaiterExpr ], m) - defunctionalize construction - |> Option.bind (fun construction -> - let captures = List.ofSeq captures.Values - - tryShareMutableCallbackCaptures g (captures |> List.filter (fun (v, _) -> v.IsMutable)) construction - |> Option.map (fun construction -> - { - Construction = construction - Tag = tag - Branches = List.ofSeq branches - Captures = captures - })) - -/// Every invocation copies every branch body, so the rewrite is skipped when the copies would exceed this many -/// expression nodes; the callback then stays a closure and a suspension left in it is reported as FS3918. -let private maxInlinedCallbackCopySize = 2000 - -/// Inlines an invocation of a defunctionalized callback: the argument is bound once, then each branch body -/// is copied with its parameter and captured locals remapped. -let private mkCallbackDispatch (g: TcGlobals) (stackGuard: StackGuard) (callback: CallbackBranches) arg resultTy m = - let argVal, _ = mkCompGenLocal m "runtimeAsyncCallbackArg" (tyOfExpr g arg) - - let captureRemap = - callback.Captures - |> List.map (fun (v, hoisted) -> v, mkLocalValRef hoisted) - |> ValMap.OfList - - let branch branch = - let remap = - { emptyRemap with - valRemap = captureRemap.Add branch.Parameter (mkLocalValRef argVal) - } - - let body = remapExpr g CloneAll remap branch.Body - - (branch.Captures, body) - ||> List.foldBack (fun v body -> - if v.IsMutable && not v.IsCompilerGenerated then - Expr.Op(TOp.DebugLocalScope(captureRemap[v], v.LogicalName), [], [ body ], m) - else - body) + let callAwaiterMember name = + let methodInfo = + match TryFindIntrinsicMethInfo infoReader m AccessorDomain.AccessibleFromEverywhere name awaiterTy with + | [ methodInfo ] -> methodInfo + | _ -> error (InternalError($"runtime-async fragment awaiter {name} not found", m)) - let rec dispatch i branches = - stackGuard.Guard(fun () -> dispatchCore i branches) + let wrap, address, _, _ = + mkExprAddrOfExpr g true false NeverMutates awaiterExpr None m - and dispatchCore i branches = - match branches with - | [ last ] -> branch last - | first :: rest -> - let isSelected = mkILAsmCeq g m (exprForVal m callback.Tag) (mkInt32 g m i) + MakeMethInfoCall amap m methodInfo [] [ address ] None |> wrap - mkCond DebugPointAtBinding.NoneAtInvisible m resultTy isSelected (branch first) (dispatch (i + 1) rest) - | [] -> failwith "unreachable: callback without branches" + let suspend = + mkCond DebugPointAtBinding.NoneAtInvisible m g.unit_ty (callAwaiterMember "get_IsCompleted") (mkUnit g m) suspend - mkCompGenLet m argVal arg (dispatch 0 callback.Branches) - -let rec private tryDelegateSignature g expr = - match expr with - | NewDelegateExpr g (_, [ parameter ], body, _, _) -> Some(parameter.Type, tyOfExpr g body) - | RuntimeAsyncDebugWrapper rest - | Expr.Sequential(_, rest, NormalSeq, _) - | Expr.Let(_, rest, _, _) -> tryDelegateSignature g rest - | Expr.Match(_, _, _, targets, _, _) when targets.Length > 0 -> - let (TTarget(_, body, _)) = targets[0] - tryDelegateSignature g body - | _ -> None + let result = callAwaiterMember "GetResult" + mkCompGenLet m awaiter createAwaiter (mkCompGenSequential m suspend result) let private outlineCallback (g: TcGlobals) amap optimizeExpr (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext expr = - let stackGuard = StackGuard("OutlineRuntimeAsyncCallbackPrototype") + let stackGuard = StackGuard("OutlineRuntimeAsyncCallback") let freeVarOptions = CollectLocalsWithStackGuard() let rec outlineBranches resultTy expr = @@ -296,14 +134,12 @@ let private outlineCallback (g: TcGlobals) amap optimizeExpr (analyzer: RuntimeA let helper = g.cgh__runtimeAsyncOutline_vref let callback = mkLambda m parameter (body, resultTy) - let outlined = - primMkApp (exprForValRef m helper, helper.Type) [ parameter.Type; resultTy ] [ callback ] m - |> optimizeExpr true - - Some outlined - | RuntimeAsyncDebugWrapper body -> + primMkApp (exprForValRef m helper, helper.Type) [ parameter.Type; resultTy ] [ callback ] m + |> optimizeExpr true + |> Some + | Expr.DebugPoint(point, body) -> outlineBranches resultTy body - |> Option.map (RebuildRuntimeAsyncDebugWrapper expr) + |> Option.map (fun body -> Expr.DebugPoint(point, body)) | Expr.Sequential(first, rest, NormalSeq, m) -> outlineBranches resultTy rest |> Option.map (fun rest -> Expr.Sequential(first, rest, NormalSeq, m)) @@ -338,14 +174,12 @@ let private outlineCallback (g: TcGlobals) amap optimizeExpr (analyzer: RuntimeA (freeInExpr freeVarOptions construction).FreeLocals |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) - match - if canCapture then - tryAnalyzeCallbackUses g callback isDelegate continuation - else - ValueNone - with - | ValueNone -> keep construction - | ValueSome _ -> + if + not canCapture + || not (canRewriteCallbackUses g callback isDelegate continuation) + then + keep construction + else let construction = outline construction match outlineBranches resultTy construction with @@ -364,86 +198,7 @@ let private outlineCallback (g: TcGlobals) amap optimizeExpr (analyzer: RuntimeA let invocation = mkApps g ((outlinedExpr, callbackTy), [], [ rewrite arg ], callRange) - let awaiterTy = mkWoNullAppTy g.runtimeAsyncFragmentAwaiter_tcref [ resultTy ] - let infoReader = InfoReader(g, amap) - - let constructor = - match GetIntrinsicConstructorInfosOfType infoReader callRange awaiterTy with - | [ constructor ] -> constructor - | _ -> - error ( - InternalError( - "runtime-async fragment awaiter constructor not found", - callRange - ) - ) - - let awaiter, awaiterExpr = - mkCompGenLocal callRange "runtimeAsyncFragmentAwaiter" awaiterTy - - let createAwaiter = - MakeMethInfoCall amap callRange constructor [] [ invocation ] None - - let awaitRef = - mkILMethRef ( - g.FindSysILTypeRef "System.Runtime.CompilerServices.AsyncHelpers", - ILCallingConv.Static, - "AwaitAwaiter", - 1, - [ mkILTyvarTy 0us ], - ILType.Void - ) - - let suspend = - Expr.Op( - TOp.ILCall( - false, - false, - false, - false, - NormalValUse, - false, - false, - awaitRef, - [], - [ awaiterTy ], - [] - ), - [], - [ awaiterExpr ], - callRange - ) - - let getResult = - match - TryFindIntrinsicMethInfo - infoReader - callRange - AccessorDomain.AccessibleFromEverywhere - "GetResult" - awaiterTy - with - | [ methodInfo ] -> methodInfo - | _ -> - error ( - InternalError( - "runtime-async fragment awaiter GetResult not found", - callRange - ) - ) - - let wrap, address, _, _ = - mkExprAddrOfExpr g true false NeverMutates awaiterExpr None callRange - - let result = MakeMethInfoCall amap callRange getResult [] [ address ] None |> wrap - - Some( - mkCompGenLet - callRange - awaiter - createAwaiter - (mkCompGenSequential callRange suspend result) - ) + Some(awaitFragment g amap resultTy invocation callRange) | ValueNone -> None) PreInterceptBinding = None PostTransform = (fun _ -> None) @@ -455,190 +210,12 @@ let private outlineCallback (g: TcGlobals) amap optimizeExpr (analyzer: RuntimeA mkLet point m outlined construction (outline continuation) | _ -> keep construction | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (outline continuation) - | RuntimeAsyncDebugWrapper body -> RebuildRuntimeAsyncDebugWrapper expr (outline body) + | Expr.DebugPoint(point, body) -> Expr.DebugPoint(point, outline body) | Expr.Sequential(first, rest, NormalSeq, m) -> Expr.Sequential(first, outline rest, NormalSeq, m) | _ -> expr) outline expr -let private inlineCallback (g: TcGlobals) (analyzer: RuntimeAsyncAnalyzer) runtimeAsyncContext (expr: Expr) = - let stackGuard = StackGuard("InlineRuntimeAsyncCallback") - let freeVarOptions = CollectLocalsWithStackGuard() - - let isTrivialValue = - function - | Expr.Const _ -> true - | Expr.Val(vref, _, _) -> vref.IsLocalRef && not vref.IsMutable && not vref.IsTypeFunction - | _ -> false - - // Construction moves to the invocation, so its evaluation must not have observable effects. - let rec tryPrepareDelegateInvocation construction = - stackGuard.Guard(fun () -> - let prepared = - match construction with - | NewDelegateExpr g (_, [ _ ], _, _, _) -> - ValueSome(fun (invokeRef, invokeTy, tyargs, arg, callRange) -> - MakeFSharpDelegateInvokeAndTryBetaReduce g (invokeRef, construction, invokeTy, tyargs, arg, callRange)) - | RuntimeAsyncDebugWrapper rest -> - tryPrepareDelegateInvocation rest - |> ValueOption.map (fun invoke -> invoke >> RebuildRuntimeAsyncDebugWrapper construction) - | Expr.Sequential(first, rest, NormalSeq, m) when isTrivialValue first -> - tryPrepareDelegateInvocation rest - |> ValueOption.map (fun invoke -> fun args -> Expr.Sequential(first, invoke args, NormalSeq, m)) - | Expr.Let((TBind(_, rhs, _)) as binding, rest, m, _) when isTrivialValue rhs -> - tryPrepareDelegateInvocation rest - |> ValueOption.map (fun invoke -> invoke >> mkLetBind m binding) - | Expr.Match(point, - matchRange, - (TDSwitch((Expr.Val _ as input), [ TCase(_, TDSuccess([], _)) ], Some(TDSuccess([], _)), _) as tree), - targets, - m, - _) when isTrivialValue input -> - let invocations = - targets - |> Array.map (fun (TTarget(_, body, _)) -> tryPrepareDelegateInvocation body) - - if targets.Length > 0 && Array.forall ValueOption.isSome invocations then - let invocations = Array.map ValueOption.get invocations - - ValueSome(fun args -> - let targets = - (targets, invocations) - ||> Array.map2 (fun (TTarget(vals, _, flags)) invoke -> TTarget(vals, invoke args, flags)) - - Expr.Match(point, matchRange, tree, targets, m, tyOfExpr g targets[0].TargetExpression)) - else - ValueNone - | _ -> ValueNone - - prepared - |> ValueOption.map (fun invoke -> fun args -> stackGuard.Guard(fun () -> invoke args))) - - // A residual Invoke can surface after ordinary optimization; try its single use before dispatching on branches. - let inlineDelegate callback invoke continuation = - let rwenv = - { - PreIntercept = - Some(fun rewrite expression -> - match expression with - | DelegateInvokeExpr g (invokeRef, invokeTy, tyargs, Expr.Val(vref, _, _), arg, callRange) when - valEq callback vref.Deref - -> - Some(invoke (invokeRef, invokeTy, tyargs, rewrite arg, callRange)) - | _ -> None) - PreInterceptBinding = None - PostTransform = (fun _ -> None) - RewriteQuotations = false - StackGuard = stackGuard - } - - RewriteExpr rwenv continuation - - let rec inlineCallbacks expr = - stackGuard.Guard(fun () -> inlineCallbacksCore expr) - - and inlineCallbacksCore expr = - match expr with - | Expr.Let(TBind(callback, construction, point), continuation, m, _) when - callback.InlineIfLambda && analyzer.ContainsSuspension construction - -> - let keep construction = - mkLetBind m (TBind(callback, construction, point)) (inlineCallbacks continuation) - - let canInline = - runtimeAsyncContext || (TryGetRuntimeAsyncReturn g continuation).IsSome - - let shape = - match tryDestFunTy g callback.Type with - | ValueSome(argTy, resultTy) -> Some(argTy, resultTy, false) - | ValueNone when isFSharpDelegateTy g callback.Type -> - tryDelegateSignature g construction - |> Option.map (fun (argTy, resultTy) -> argTy, resultTy, true) - | _ -> None - - match shape with - | Some(argTy, resultTy, isDelegate) when canInline && not (isByrefLikeTy g m argTy || isByrefLikeTy g m resultTy) -> - let freeVals = (freeInExpr freeVarOptions construction).FreeLocals - - let canCapture = - freeVals - |> Zset.forall (fun v -> not v.IsPinning && not (isByrefTy g v.Type || isByrefLikeTy g m v.Type)) - - let construction = - if canCapture then - inlineCallbacks construction - else - construction - - let callbackUses = - if canCapture then - tryAnalyzeCallbackUses g callback isDelegate continuation - else - ValueNone - - let inlined = - match callbackUses with - | ValueSome 1 when isDelegate -> - tryPrepareDelegateInvocation construction - |> ValueOption.map (fun invoke -> inlineDelegate callback invoke continuation) - |> ValueOption.toOption - | _ -> None - - let callbackBranches = - match callbackUses with - | ValueSome invocations when Option.isNone inlined -> - tryDefunctionalizeCallback g stackGuard m construction - |> Option.filter (fun branches -> - invocations = 0 - || List.sumBy _.NodeCount branches.Branches - <= maxInlinedCallbackCopySize / invocations) - | _ -> None - - match inlined, callbackBranches with - | Some body, _ -> inlineCallbacks body - | None, Some callbackBranches -> - let rwenv = - { - PreIntercept = - Some(fun rewrite expression -> - match tryCallbackInvocation g callback isDelegate expression with - | ValueSome(struct (arg, callRange)) -> - Some( - mkCallbackDispatch - g - stackGuard - callbackBranches - (rewrite arg) - (tyOfExpr g expression) - callRange - ) - | ValueNone -> None) - PreInterceptBinding = None - PostTransform = (fun _ -> None) - RewriteQuotations = false - StackGuard = stackGuard - } - - let continuation = RewriteExpr rwenv continuation - - let construction = - match point with - | DebugPointAtBinding.Yes point -> mkDebugPoint point callbackBranches.Construction - | _ -> callbackBranches.Construction - - let body = mkCompGenSequential m construction (inlineCallbacks continuation) - - (callbackBranches.Tag :: List.map snd callbackBranches.Captures, body) - ||> List.foldBack (fun v body -> mkCompGenLet m v (mkDefault (m, v.Type)) body) - | None, None -> keep construction - | _ -> keep construction - | Expr.Let(binding, continuation, m, _) -> mkLetBind m binding (inlineCallbacks continuation) - | RuntimeAsyncDebugWrapper body -> RebuildRuntimeAsyncDebugWrapper expr (inlineCallbacks body) - | Expr.Sequential(first, rest, NormalSeq, m) -> Expr.Sequential(first, inlineCallbacks rest, NormalSeq, m) - | _ -> expr - - inlineCallbacks expr - let private isRuntimeAsyncEntry g expr = (TryGetRuntimeAsyncReturn g expr).IsSome || (TryGetRuntimeAsyncSequence g expr).IsSome @@ -656,20 +233,15 @@ let private containsRuntimeAsyncEntry g implFile = FoldImplFile folder false implFile -/// Inlines suspending InlineIfLambda callbacks into the enclosing method. `inContext` is true inside a runtime-async body or -/// sequence recipe, including nested lambdas and delegates, but not object-expression methods, which are -/// compiled as ordinary methods. -let private inlineCallbacks g amap optimizeExpr implFile = +/// `inContext` follows runtime-async bodies and sequence recipes, but not ordinary object-expression methods. +let private outlineCallbacks g amap optimizeExpr implFile = let analyzer = RuntimeAsyncAnalyzer g - let stackGuard = StackGuard("InlineRuntimeAsyncCallbacks") - - let outliningPrototype = - System.Environment.GetEnvironmentVariable("FSHARP_RUNTIME_ASYNC_OUTLINING_PROTOTYPE") = "1" + let stackGuard = StackGuard("OutlineRuntimeAsyncCallbacks") let rec leadsToRuntimeAsyncReturn expr = match expr with | Expr.Let(_, rest, _, _) - | RuntimeAsyncDebugWrapper rest + | Expr.DebugPoint(_, rest) | Expr.Sequential(_, rest, NormalSeq, _) -> leadsToRuntimeAsyncReturn rest | _ -> (TryGetRuntimeAsyncReturn g expr).IsSome @@ -690,8 +262,6 @@ let private inlineCallbacks g amap optimizeExpr implFile = Some(Expr.App(f, fty, tyargs, [ rewrite true body ], m)) | Expr.App(f, fty, tyargs, [ recipe ], m) when (TryGetRuntimeAsyncSequence g expr).IsSome -> Some(Expr.App(f, fty, tyargs, [ rewrite true recipe ], m)) - // A callback constructed for a runtime-async body that follows is lowered as part of that body, - // so the suspending callbacks it captures can be inlined into it. | Expr.Let(TBind(callback, construction, point), continuation, m, _) when not inContext && callback.InlineIfLambda @@ -709,16 +279,10 @@ let private inlineCallbacks g amap optimizeExpr implFile = | Expr.Obj _ when inContext -> Some(rewrite false expr) | _ -> None - // Bottom-up: callbacks nested in the construction or continuation are already inlined. and postTransform inContext expr = match expr with | Expr.Let(TBind(callback, construction, _), _, _, _) when callback.InlineIfLambda && analyzer.ContainsSuspension construction -> - Some( - if outliningPrototype then - outlineCallback g amap optimizeExpr analyzer inContext expr - else - inlineCallback g analyzer inContext expr - ) + Some(outlineCallback g amap optimizeExpr analyzer inContext expr) | _ -> None RewriteImplFile (rewriter false) implFile @@ -752,7 +316,7 @@ let TransformImplFile (g: TcGlobals) amap optimizeExpr reportedRanges (implFile: // Bodies inlined from an assembly compiled with the feature are still prepared. let implFile = if g.langVersion.SupportsFeature LanguageFeature.RuntimeAsync then - inlineCallbacks g amap optimizeExpr implFile + outlineCallbacks g amap optimizeExpr implFile else implFile diff --git a/src/Compiler/Optimize/LowerRuntimeAsync.fsi b/src/Compiler/Optimize/LowerRuntimeAsync.fsi index 95c5f84af2f..07fc70b9d88 100644 --- a/src/Compiler/Optimize/LowerRuntimeAsync.fsi +++ b/src/Compiler/Optimize/LowerRuntimeAsync.fsi @@ -2,8 +2,8 @@ /// Post-optimization lowering for runtime-async bodies. /// -/// Runs after the first optimization loop and before LowerLocalMutables. It inlines suspending -/// InlineIfLambda callbacks that the optimizer could not inline into the enclosing method, then +/// Runs after the first optimization loop and before LowerLocalMutables. It outlines suspending +/// InlineIfLambda callbacks that the optimizer could not inline, using the Core context-handoff template, then /// prepares every `__runtimeAsyncReturn*` body exactly once (non-preservable local diagnostics and /// exception-handler rewriting). `__runtimeAsyncSequence` recipes are not prepared here: LowerAsyncSeq /// prepares their MoveNextAsync body after state-machine conversion. diff --git a/src/Compiler/Optimize/Optimizer.fs b/src/Compiler/Optimize/Optimizer.fs index b23769953dc..9f7f4695623 100644 --- a/src/Compiler/Optimize/Optimizer.fs +++ b/src/Compiler/Optimize/Optimizer.fs @@ -1739,7 +1739,6 @@ and BindingHasEffect context g bind = bind.Expr |> ExprHasEffect context g and OpHasEffect context g m op tyargs = match op with - | TOp.DebugLocalScope _ -> false | TOp.Tuple _ -> false | TOp.AnonRecd _ -> false | TOp.Recd (ctor, tcref) -> @@ -3099,7 +3098,6 @@ and OptimizeExprOpFallback cenv env (op, tyargs, argsR, m) arginfos value_ = let effect = OpHasEffect (effectContextOf cenv) g m op tyargs let cost, value_ = match op with - | TOp.DebugLocalScope _ -> 0, argValues[0] | TOp.UnionCase c -> 2, MakeValueInfoForUnionCase c (Array.ofList argValues) | TOp.ExnConstr _ -> 2, value_ diff --git a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs index 5d98dc1b314..b4a0fc2a551 100644 --- a/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs +++ b/src/Compiler/Optimize/RuntimeAsyncAnalysis.fs @@ -165,10 +165,9 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt | _ -> false -> apply inner fty tyargs args m - | Expr.DebugPoint(_, inner), _ -> apply inner fty tyargs args m |> Option.map (RebuildRuntimeAsyncDebugWrapper f) - | RuntimeAsyncDebugWrapper body, _ -> - apply body (tyOfExpr g body) tyargs args m - |> Option.map (RebuildRuntimeAsyncDebugWrapper f) + | Expr.DebugPoint(point, inner), _ -> + apply inner fty tyargs args m + |> Option.map (fun body -> Expr.DebugPoint(point, body)) | Expr.Let(binding, body, mLet, _), _ -> apply body (tyOfExpr g body) tyargs args m |> Option.map (mkLetBind mLet binding) @@ -197,11 +196,10 @@ let ReduceRuntimeAsyncReturnedClosureApplications (g: TcGlobals) (analyzer: Runt PostTransform = (fun expression -> match expression with - | Expr.App((Expr.Lambda _ | Expr.Let _ | Expr.LetRec _ | Expr.Match _ | RuntimeAsyncDebugWrapper _) as f, - fty, - tyargs, - args, - m) when not args.IsEmpty && analyzer.ContainsSuspension expression -> apply f fty tyargs args m + | Expr.App((Expr.Lambda _ | Expr.Let _ | Expr.LetRec _ | Expr.Match _ | Expr.DebugPoint _) as f, fty, tyargs, args, m) when + not args.IsEmpty && analyzer.ContainsSuspension expression + -> + apply f fty tyargs args m | _ -> None) RewriteQuotations = false StackGuard = stackGuard diff --git a/src/Compiler/TypedTree/RuntimeAsync.fs b/src/Compiler/TypedTree/RuntimeAsync.fs index 90569f688e0..95ceac9be98 100644 --- a/src/Compiler/TypedTree/RuntimeAsync.fs +++ b/src/Compiler/TypedTree/RuntimeAsync.fs @@ -10,18 +10,6 @@ open FSharp.Compiler.TypedTreeOps type RuntimeAsyncReturnInfo = { Body: Expr; TypeArgs: TType list } -let (|RuntimeAsyncDebugWrapper|_|) expr = - match expr with - | Expr.DebugPoint(_, body) - | Expr.Op(TOp.DebugLocalScope _, [], [ body ], _) -> ValueSome body - | _ -> ValueNone - -let RebuildRuntimeAsyncDebugWrapper wrapper body = - match wrapper with - | Expr.DebugPoint(point, _) -> Expr.DebugPoint(point, body) - | Expr.Op(TOp.DebugLocalScope _ as op, [], [ _ ], m) -> Expr.Op(op, [], [ body ], m) - | _ -> failwith "unreachable: expression is not a runtime-async debug wrapper" - let TryMapRuntimeAsyncMatchTargets (g: TcGlobals) matchInfo mapTarget = let point, matchRange, tree, (targets: DecisionTreeTarget array), m = matchInfo diff --git a/src/Compiler/TypedTree/RuntimeAsync.fsi b/src/Compiler/TypedTree/RuntimeAsync.fsi index 016a2863167..357394e9e53 100644 --- a/src/Compiler/TypedTree/RuntimeAsync.fsi +++ b/src/Compiler/TypedTree/RuntimeAsync.fsi @@ -10,11 +10,6 @@ open FSharp.Compiler.TypedTree type RuntimeAsyncReturnInfo = { Body: Expr; TypeArgs: TType list } -val (|RuntimeAsyncDebugWrapper|_|): expr: Expr -> Expr voption - -/// Retains the original debug range or lexical scope when rebuilding a matched wrapper. -val RebuildRuntimeAsyncDebugWrapper: wrapper: Expr -> body: Expr -> Expr - /// Rebuilds a match with every target rewritten, or returns None if any target cannot be. val TryMapRuntimeAsyncMatchTargets: g: TcGlobals -> diff --git a/src/Compiler/TypedTree/TypedTree.fs b/src/Compiler/TypedTree/TypedTree.fs index 45bdbe5fe64..4f62c6f9ace 100644 --- a/src/Compiler/TypedTree/TypedTree.fs +++ b/src/Compiler/TypedTree/TypedTree.fs @@ -5555,9 +5555,6 @@ type TOp = /// Operation nodes representing C-style operations on byrefs and mutable vals (l-values) | LValueOp of LValueOperation * ValRef - /// Exposes existing storage under a source name for the scope of the single body argument. - | DebugLocalScope of ValRef * name: string - /// IL method calls. /// isProperty -- used for quotation reflection, property getters & setters /// noTailCall - DllImport? if so don't tailcall @@ -5613,7 +5610,6 @@ type TOp = | Label n -> "Label(" + string n + ")" | TraitCall info -> "TraitCall(" + info.MemberLogicalName + ")" | LValueOp (op, vref) -> sprintf "%+A(%s)" op vref.LogicalName - | DebugLocalScope (_, name) -> "DebugLocalScope(" + name + ")" | ILCall (_,_,_,_,_,_,_,ilMethRef,_,_,_) -> "ILCall(" + ilMethRef.ToString() + ",..)" /// Represents the kind of record construction operation. diff --git a/src/Compiler/TypedTree/TypedTree.fsi b/src/Compiler/TypedTree/TypedTree.fsi index 2a804a3aeb5..d93c232341f 100644 --- a/src/Compiler/TypedTree/TypedTree.fsi +++ b/src/Compiler/TypedTree/TypedTree.fsi @@ -3921,9 +3921,6 @@ type TOp = /// Operation nodes representing C-style operations on byrefs type mutable vals (l-values) | LValueOp of LValueOperation * ValRef - /// Exposes existing storage under a source name for the scope of the single body argument. - | DebugLocalScope of ValRef * name: string - /// IL method calls. /// isProperty -- used for quotation reflection, property getters & setters /// noTailCall - DllImport? if so don't tailcall diff --git a/src/Compiler/TypedTree/TypedTreeOps.Remapping.fs b/src/Compiler/TypedTree/TypedTreeOps.Remapping.fs index 98e31cca75e..df53a1675b4 100644 --- a/src/Compiler/TypedTree/TypedTreeOps.Remapping.fs +++ b/src/Compiler/TypedTree/TypedTreeOps.Remapping.fs @@ -1272,8 +1272,7 @@ module internal ExprFreeVars = sln.Value (accFreeVarsInTys opts tys (accFreeVarsInTys opts argTys (Option.foldBack (accFreeVarsInTy opts) retTy acc))) - | TOp.LValueOp(_, vref) - | TOp.DebugLocalScope(vref, _) -> accFreeValRef opts vref acc + | TOp.LValueOp(_, vref) -> accFreeValRef opts vref acc | TOp.ILCall(_, isProtected, _, _, valUseFlag, _, _, _, enclTypeInst, methInst, retTypes) -> accFreeVarsInTys @@ -2062,7 +2061,6 @@ module internal ExprRemapping = TOp.ILAsm(instrs, retTypes2) | TOp.TraitCall traitInfo -> TOp.TraitCall(remapTraitInfo tmenv traitInfo) | TOp.LValueOp(kind, lvr) -> TOp.LValueOp(kind, remapValRef tmenv lvr) - | TOp.DebugLocalScope(vref, name) -> TOp.DebugLocalScope(remapValRef tmenv vref, name) | TOp.ILCall(isVirtual, isProtected, isStruct, @@ -2773,7 +2771,6 @@ module internal ExprAnalysis = | Expr.DebugPoint(_, e) | Expr.StaticOptimization(_, _, e, _) | Expr.LetRec(_, e, _, _) -> tyOfExpr g e - | Expr.Op(TOp.DebugLocalScope _, _, [ body ], _) -> tyOfExpr g body | Expr.Op(op, tinst, _, _) -> match op with | TOp.Coerce -> @@ -2828,7 +2825,6 @@ module internal ExprAnalysis = (match tinst with | [ rtn_ty ] -> rtn_ty | _ -> failwith "bad TOp.Reraise node") - | TOp.DebugLocalScope _ -> failwith "bad TOp.DebugLocalScope node" | TOp.Goto _ | TOp.Label _ | TOp.Return -> diff --git a/src/Compiler/TypedTree/TypedTreePickle.fs b/src/Compiler/TypedTree/TypedTreePickle.fs index 24d4143766b..dbe6964b62c 100644 --- a/src/Compiler/TypedTree/TypedTreePickle.fs +++ b/src/Compiler/TypedTree/TypedTreePickle.fs @@ -3666,8 +3666,7 @@ and p_op x st = p_int n st | TOp.Goto _ | TOp.Label _ - | TOp.Return - | TOp.DebugLocalScope _ -> failwith "unexpected backend construct in pickled TAST" + | TOp.Return -> failwith "unexpected backend construct in pickled TAST" and u_op st = let tag = u_byte st diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index d8ca1d08d8c..a15b6f7d2e8 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1985,13 +1985,13 @@ let main _ = ] Assert.True(cliResult.ExitCode = 0, $"{cliResult.StdOut}\n{cliResult.StdErr}") - [] - [] - [] - [] - [] + [] + [] + [] + [] + [] [] - let ``runtime async bounds branch-selected callback copies across all invocations`` (invocations: int, nested: bool, withinBudget: bool) = + let ``runtime async outlines branch-selected callbacks without copying bodies per invocation`` (invocations: int, nested: bool) = let calls = if nested then [1 .. invocations] |> List.fold (fun arg _ -> $"f ({arg})") "1" @@ -2034,10 +2034,17 @@ let main _ = |> withLangVersionPreview |> withFSharpCoreShippedNet - if withinBudget then - compilation |> compileExeAndRun |> shouldSucceed - else - compilation |> compile |> shouldFail |> withErrorCode 3918 + compilation + |> compileExeAndRun + |> shouldSucceed + |> withMetadataReader (fun md -> + let asyncInvokes = + md.MethodDefinitions + |> Seq.filter (fun handle -> + let method = md.GetMethodDefinition handle + md.GetString method.Name = "Invoke" && int method.ImplAttributes &&& 0x2000 <> 0) + |> Seq.length + Assert.Equal(4, asyncInvokes)) [] [] @@ -2221,19 +2228,18 @@ let main _ = |> compileExeAndRun |> shouldSucceed - if System.Environment.GetEnvironmentVariable("FSHARP_RUNTIME_ASYNC_OUTLINING_PROTOTYPE") = "1" then - result - |> verifyILContains [ - "call instance !0 valuetype [FSharp.Core]Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1::GetResult()" - ] - |> ignore + result + |> verifyILContains [ + "call instance !0 valuetype [FSharp.Core]Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1::GetResult()" + ] + |> ignore [] [] [] [] [] - let ``runtime async callback does not add context change notifications`` (optimize: bool, pending: bool) = + let ``runtime async outlined callback reports child context exit and restoration`` (optimize: bool, pending: bool) = let result = FSharp $""" module RuntimeAsyncContextNotifications @@ -2272,8 +2278,8 @@ let run (gate: Task) flag = fun () -> 0) recording <- false if context.Value <> "inner" then failwith "Incorrect final context" - if trace.ToArray() <> [| "outer->inner:False" |] then - failwithf "Additional context notifications: %%A" (trace.ToArray()) + if trace.ToArray() <> [| "outer->inner:False"; "inner->outer:True"; "outer->inner:True" |] then + failwithf "Unexpected context notifications: %%A" (trace.ToArray()) value) [] @@ -2281,6 +2287,7 @@ let main _ = let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) if {if pending then "false" else "true"} then gate.SetResult(()) let result = run gate.Task true + if {if pending then "false" else "true"} && not result.IsCompleted then failwith "Completed callback suspended" gate.TrySetResult(()) |> ignore if result.GetAwaiter().GetResult() <> 41 then failwith "Incorrect result" 0 @@ -2291,12 +2298,11 @@ let main _ = |> compileExeAndRun |> shouldSucceed - if System.Environment.GetEnvironmentVariable("FSHARP_RUNTIME_ASYNC_OUTLINING_PROTOTYPE") = "1" then - result - |> verifyILContains [ - "call instance !0 valuetype [FSharp.Core]Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1::GetResult()" - ] - |> ignore + result + |> verifyILContains [ + "call instance !0 valuetype [FSharp.Core]Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1::GetResult()" + ] + |> ignore [] [] @@ -2539,7 +2545,7 @@ let main _ = [] [] [] - let ``runtime async inlines nested single-use delegate sources`` (optimize: bool) = + let ``runtime async outlines nested single-use delegate sources`` (optimize: bool) = let source = FSharp """ module NestedDelegateSource @@ -2594,8 +2600,6 @@ let main _ = |> compileExeAndRun |> shouldSucceed - // The construction in executeWithConstruction precedes its runtime-async body, which therefore starts as - // a closure; callbacks are inlined, so that closure is the only async Invoke. source |> withMetadataReader (fun md -> let asyncInvokes = [ for handle in md.TypeDefinitions do @@ -2604,8 +2608,8 @@ let main _ = let method = md.GetMethodDefinition methodHandle if md.GetString method.Name = "Invoke" && int method.ImplAttributes &&& 0x2000 <> 0 then yield md.GetString ty.Name ] - let asyncInvoke = Assert.Single asyncInvokes - Assert.StartsWith("executeWithConstruction@", asyncInvoke)) + Assert.Equal(4, asyncInvokes.Length) + Assert.Contains(asyncInvokes, fun name -> name.StartsWith("executeWithConstruction@", System.StringComparison.Ordinal))) [] [] @@ -3045,14 +3049,16 @@ let main _ = pdb.GetMethodDebugInformation(handle).GetSequencePoints() |> Seq.exists (fun point -> not point.IsHidden && point.StartLine = 19)) |> Assert.Single - let invoked = - localsAt invocationMethod 19 - |> List.filter (fun (name, _) -> name = "count") - |> Assert.Single - |> snd - Assert.Equal(LocalVariableAttributes.None, invoked.Attributes) - if insideContext then - Assert.Equal(inner.Index, invoked.Index)) - if insideContext then - result |> verifyILNotPresent [ "FSharpRef" ] + if invocationMethod = method then + let invoked = + localsAt invocationMethod 19 + |> List.filter (fun (name, _) -> name = "count") + |> Assert.Single + |> snd + Assert.Equal(LocalVariableAttributes.None, invoked.Attributes) + Assert.Equal(inner.Index, invoked.Index) + else + let closure = md.GetTypeDefinition(md.GetMethodDefinition(invocationMethod).GetDeclaringType()) + let fields = closure.GetFields() |> Seq.map (fun handle -> md.GetString(md.GetFieldDefinition(handle).Name)) + Assert.Contains("count", fields)) #endif diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netcore.debug.bsl b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netcore.debug.bsl index 6964d31c0e3..ba2c8f3362d 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netcore.debug.bsl +++ b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netcore.debug.bsl @@ -1010,6 +1010,11 @@ Microsoft.FSharp.Core.CompilerServices.ResumptionFunc`1[TData]: Boolean EndInvok Microsoft.FSharp.Core.CompilerServices.ResumptionFunc`1[TData]: Boolean Invoke(Microsoft.FSharp.Core.CompilerServices.ResumableStateMachine`1[TData] ByRef) Microsoft.FSharp.Core.CompilerServices.ResumptionFunc`1[TData]: System.IAsyncResult BeginInvoke(Microsoft.FSharp.Core.CompilerServices.ResumableStateMachine`1[TData] ByRef, System.AsyncCallback, System.Object) Microsoft.FSharp.Core.CompilerServices.ResumptionFunc`1[TData]: Void .ctor(System.Object, IntPtr) +Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1[T]: Boolean IsCompleted +Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1[T]: Boolean get_IsCompleted() +Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1[T]: T GetResult() +Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1[T]: Void .ctor(System.Threading.Tasks.ValueTask`1[Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentResult`1[T]]) +Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentResult`1[T]: Void .ctor(Microsoft.FSharp.Core.FSharpResult`2[T,System.Runtime.ExceptionServices.ExceptionDispatchInfo]) Microsoft.FSharp.Core.CompilerServices.RuntimeHelpers: Microsoft.FSharp.Control.IEvent`2[TDelegate,TArgs] CreateEvent[TDelegate,TArgs](Microsoft.FSharp.Core.FSharpFunc`2[TDelegate,Microsoft.FSharp.Core.Unit], Microsoft.FSharp.Core.FSharpFunc`2[TDelegate,Microsoft.FSharp.Core.Unit], Microsoft.FSharp.Core.FSharpFunc`2[Microsoft.FSharp.Core.FSharpFunc`2[System.Object,Microsoft.FSharp.Core.FSharpFunc`2[TArgs,Microsoft.FSharp.Core.Unit]],TDelegate]) Microsoft.FSharp.Core.CompilerServices.RuntimeHelpers: System.Collections.Generic.IEnumerable`1[TResult] EnumerateFromFunctions[T,TResult](Microsoft.FSharp.Core.FSharpFunc`2[Microsoft.FSharp.Core.Unit,T], Microsoft.FSharp.Core.FSharpFunc`2[T,System.Boolean], Microsoft.FSharp.Core.FSharpFunc`2[T,TResult]) Microsoft.FSharp.Core.CompilerServices.RuntimeHelpers: System.Collections.Generic.IEnumerable`1[TResult] EnumerateUsing[T,TCollection,TResult](T, Microsoft.FSharp.Core.FSharpFunc`2[T,TCollection]) @@ -1021,6 +1026,7 @@ Microsoft.FSharp.Core.CompilerServices.SetStateMachineMethodImpl`1[TData]: Void Microsoft.FSharp.Core.CompilerServices.SetStateMachineMethodImpl`1[TData]: Void EndInvoke(System.IAsyncResult) 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.FSharpFunc`2[TArg,System.Threading.Tasks.ValueTask`1[Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentResult`1[T]]] __runtimeAsyncOutline[TArg,T](Microsoft.FSharp.Core.FSharpFunc`2[TArg,T]) Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers: Microsoft.FSharp.Core.FSharpOption`1[System.Int32] __resumableEntry() Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers: System.Collections.Generic.IAsyncEnumerable`1[T] __runtimeAsyncSequence[T](Microsoft.FSharp.Core.FSharpFunc`2[Microsoft.FSharp.Core.Unit,System.Collections.Generic.IEnumerable`1[T]]) Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers: System.Threading.CancellationToken __runtimeAsyncSequenceCancellationToken() diff --git a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netcore.release.bsl b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netcore.release.bsl index 4b13d5aa212..d50d8462ed9 100644 --- a/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netcore.release.bsl +++ b/tests/FSharp.Core.UnitTests/FSharp.Core.SurfaceArea.netcore.release.bsl @@ -1010,6 +1010,11 @@ Microsoft.FSharp.Core.CompilerServices.ResumptionFunc`1[TData]: Boolean EndInvok Microsoft.FSharp.Core.CompilerServices.ResumptionFunc`1[TData]: Boolean Invoke(Microsoft.FSharp.Core.CompilerServices.ResumableStateMachine`1[TData] ByRef) Microsoft.FSharp.Core.CompilerServices.ResumptionFunc`1[TData]: System.IAsyncResult BeginInvoke(Microsoft.FSharp.Core.CompilerServices.ResumableStateMachine`1[TData] ByRef, System.AsyncCallback, System.Object) Microsoft.FSharp.Core.CompilerServices.ResumptionFunc`1[TData]: Void .ctor(System.Object, IntPtr) +Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1[T]: Boolean IsCompleted +Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1[T]: Boolean get_IsCompleted() +Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1[T]: T GetResult() +Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentAwaiter`1[T]: Void .ctor(System.Threading.Tasks.ValueTask`1[Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentResult`1[T]]) +Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentResult`1[T]: Void .ctor(Microsoft.FSharp.Core.FSharpResult`2[T,System.Runtime.ExceptionServices.ExceptionDispatchInfo]) Microsoft.FSharp.Core.CompilerServices.RuntimeHelpers: Microsoft.FSharp.Control.IEvent`2[TDelegate,TArgs] CreateEvent[TDelegate,TArgs](Microsoft.FSharp.Core.FSharpFunc`2[TDelegate,Microsoft.FSharp.Core.Unit], Microsoft.FSharp.Core.FSharpFunc`2[TDelegate,Microsoft.FSharp.Core.Unit], Microsoft.FSharp.Core.FSharpFunc`2[Microsoft.FSharp.Core.FSharpFunc`2[System.Object,Microsoft.FSharp.Core.FSharpFunc`2[TArgs,Microsoft.FSharp.Core.Unit]],TDelegate]) Microsoft.FSharp.Core.CompilerServices.RuntimeHelpers: System.Collections.Generic.IEnumerable`1[TResult] EnumerateFromFunctions[T,TResult](Microsoft.FSharp.Core.FSharpFunc`2[Microsoft.FSharp.Core.Unit,T], Microsoft.FSharp.Core.FSharpFunc`2[T,System.Boolean], Microsoft.FSharp.Core.FSharpFunc`2[T,TResult]) Microsoft.FSharp.Core.CompilerServices.RuntimeHelpers: System.Collections.Generic.IEnumerable`1[TResult] EnumerateUsing[T,TCollection,TResult](T, Microsoft.FSharp.Core.FSharpFunc`2[T,TCollection]) @@ -1021,6 +1026,7 @@ Microsoft.FSharp.Core.CompilerServices.SetStateMachineMethodImpl`1[TData]: Void Microsoft.FSharp.Core.CompilerServices.SetStateMachineMethodImpl`1[TData]: Void EndInvoke(System.IAsyncResult) 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.FSharpFunc`2[TArg,System.Threading.Tasks.ValueTask`1[Microsoft.FSharp.Core.CompilerServices.RuntimeAsyncFragmentResult`1[T]]] __runtimeAsyncOutline[TArg,T](Microsoft.FSharp.Core.FSharpFunc`2[TArg,T]) Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers: Microsoft.FSharp.Core.FSharpOption`1[System.Int32] __resumableEntry() Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers: System.Collections.Generic.IAsyncEnumerable`1[T] __runtimeAsyncSequence[T](Microsoft.FSharp.Core.FSharpFunc`2[Microsoft.FSharp.Core.Unit,System.Collections.Generic.IEnumerable`1[T]]) Microsoft.FSharp.Core.CompilerServices.StateMachineHelpers: System.Threading.CancellationToken __runtimeAsyncSequenceCancellationToken() From 398d604beece479525f4e544fcc1fe110ebf4db1 Mon Sep 17 00:00:00 2001 From: majocha <1760221+majocha@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:12:09 +0200 Subject: [PATCH 29/30] fix merge --- src/Compiler/Optimize/Optimizer.fs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Compiler/Optimize/Optimizer.fs b/src/Compiler/Optimize/Optimizer.fs index f09c4280b8d..d792dec8345 100644 --- a/src/Compiler/Optimize/Optimizer.fs +++ b/src/Compiler/Optimize/Optimizer.fs @@ -513,7 +513,6 @@ type IncrementalOptimizationEnv = methEnv = { pipelineCount = 0 } referencedCcus = [] earlierImplFileSignatures = [] - debugInlineCallSite = None runtimeAsyncContext = false } override x.ToString() = "" From 0a93d63aa491ac0a22281719feb2a115b8faa018 Mon Sep 17 00:00:00 2001 From: Jakub Majocha <1760221+majocha@users.noreply.github.com> Date: Mon, 12 Oct 2026 21:37:24 +0200 Subject: [PATCH 30/30] Update tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs Co-authored-by: Tomas Grosup --- .../Language/RuntimeAsyncTests.fs | 50 ++++++++++++------- 1 file changed, 33 insertions(+), 17 deletions(-) diff --git a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs index a15b6f7d2e8..65881164b4a 100644 --- a/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs +++ b/tests/FSharp.Compiler.ComponentTests/Language/RuntimeAsyncTests.fs @@ -1477,12 +1477,16 @@ let main _ = |> compileExeAndRun |> shouldSucceed - [] - [] - [] - [] + [] + [] + [] + [] + [] + [] + [] + [] [] - let ``Issue 20577 preserves stateful conditional fold callbacks`` (optimize: bool, useList: bool) = + let ``Issue 20577 preserves stateful conditional fold callbacks`` (optimize: bool, useList: bool, completed: bool) = let source = $""" module RuntimeAsyncConditionalFoldTest @@ -1490,34 +1494,46 @@ open System.Collections.Generic open System.Threading.Tasks open RuntimeTaskBuilder.RuntimeTask +[] let run deduplicate (ready: Task) = runtimeTask {{ let! initial = ready - let folder = - if deduplicate then - let seen = HashSet() - fun total item -> if seen.Add item then total + item else total - else - fun total item -> total + item - return - {if useList then "List.fold folder initial [ 1; 1; 2 ]" else "Array.fold folder initial [| 1; 1; 2 |]"} + {if useList then "List.fold" else "Array.fold"} + (if deduplicate then + let seen = HashSet() + fun total item -> if seen.Add item then total + item else total + else + fun total item -> total + item) + initial + {if useList then "[ 1; 1; 2 ]" else "[| 1; 1; 2 |]"} }} [] let main _ = + let completed = {if completed then "true" else "false"} for deduplicate in [ true; false ] do let gate = TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously) + if completed then gate.SetResult 0 let work = run deduplicate gate.Task - if work.IsCompleted then failwith "Expected suspension" - gate.SetResult 0 + if not completed then + if work.IsCompleted then failwith "Expected suspension" + gate.SetResult 0 let expected = if deduplicate then 3 else 4 if work.GetAwaiter().GetResult() <> expected then failwith "Callback state was lost" 0 """ - FsFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTaskBuilder.fs")) - |> withAdditionalSourceFile (FsSourceWithFileName "RuntimeAsyncConditionalFoldTest.fs" source) + let library = + FsFromPath (Path.Combine(__SOURCE_DIRECTORY__, "RuntimeAsync", "RuntimeTaskBuilder.fs")) + |> asLibrary + |> withName "RuntimeTaskLibrary" + |> withLangVersionPreview + |> withFSharpCoreShippedNet + |> withOptimization optimize + + FSharp source + |> withReferences [ library ] |> withLangVersionPreview |> withFSharpCoreShippedNet |> withOptimization optimize