From 02cc713fe7a0899e720f81005e4587d7b1faa85c Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Tue, 24 Mar 2026 11:28:02 +0100 Subject: [PATCH 1/3] Fix SRTP overload resolution across FSI submissions (#12386) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: The TTrait solution ref cell is shared between typar constraints and expression tree nodes (TOp.TraitCall). After typechecking, the codegen/ optimizer path calls SolveMemberConstraint with NoTrace, permanently mutating the shared ref cell. In FSI, this mutation bleeds into the TcEnv carried to the next submission, causing SolveMemberConstraint to return early with a stale solution that doesn't unify the return type — triggering FS0030. In compiled code (cross-DLL), pickling naturally creates fresh ref cells, so the consumer starts with clean constraints. FSI had no equivalent isolation. Fix: At generalization time in GeneralizeVal, when running in interactive mode, decouple the typar constraints' TTrait solution ref cells from the expression tree by cloning with fresh ref cells (TraitConstraintInfo.CloneWithFreshSolution). This way: - Same-submission call sites still see the solution (via the typar's own cell, which has the canonicalization result copied at clone time) - Codegen mutates the expression tree's original cell (unaffected) - Next submission instantiates from the typar's decoupled cell (no codegen contamination) The fix is guarded by isInteractive so it has zero impact on batch compilation. This matches the cross-DLL behavior where pickling creates independent cells. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Checking/Expressions/CheckExpressions.fs | 5 ++ src/Compiler/TypedTree/TypedTree.fs | 4 ++ src/Compiler/TypedTree/TypedTree.fsi | 2 + src/Compiler/TypedTree/TypedTreeOps.Remap.fs | 13 ++++ src/Compiler/TypedTree/TypedTreeOps.Remap.fsi | 3 + .../Scripting/Interactive.fs | 70 +++++++++++++++++++ 6 files changed, 97 insertions(+) diff --git a/src/Compiler/Checking/Expressions/CheckExpressions.fs b/src/Compiler/Checking/Expressions/CheckExpressions.fs index 5a6bbf619fa..ff9f3c89bf0 100644 --- a/src/Compiler/Checking/Expressions/CheckExpressions.fs +++ b/src/Compiler/Checking/Expressions/CheckExpressions.fs @@ -1690,6 +1690,11 @@ let GeneralizeVal (cenv: cenv) denv enclosingDeclaredTypars generalizedTyparsFor // This is just about the only place we form a GeneralizedType let tyScheme = GeneralizedType(generalizedTypars, ty) + // Decouple SRTP solution ref cells so codegen mutations don't bleed across FSI submissions. + // In compiled code, pickling naturally creates fresh cells. See #12386. + if cenv.g.isInteractive then + decoupleTraitSolutions generalizedTypars + PrelimVal2(id, tyScheme, prelimValReprInfo, memberInfoOpt, isMutable, inlineFlag, baseOrThis, argAttribs, vis, isCompGen, hasDeclaredTypars) let GeneralizeVals (cenv: cenv) denv enclosingDeclaredTypars generalizedTypars types = diff --git a/src/Compiler/TypedTree/TypedTree.fs b/src/Compiler/TypedTree/TypedTree.fs index 995071138b5..733b269b588 100644 --- a/src/Compiler/TypedTree/TypedTree.fs +++ b/src/Compiler/TypedTree/TypedTree.fs @@ -2631,6 +2631,10 @@ type TraitConstraintInfo = with get() = (let (TTrait(solution = sln)) = x in sln.Value) and set v = (let (TTrait(solution = sln)) = x in sln.Value <- v) + member x.CloneWithFreshSolution() = + let (TTrait(a, b, c, d, e, f, sln)) = x + TTrait(a, b, c, d, e, f, ref sln.Value) + member x.WithMemberKind(kind) = (let (TTrait(a, b, c, d, e, f, g)) = x in TTrait(a, b, { c with MemberKind=kind }, d, e, f, g)) member x.WithSupportTypes(tys) = (let (TTrait(_, b, c, d, e, f, g)) = x in TTrait(tys, b, c, d, e, f, g)) diff --git a/src/Compiler/TypedTree/TypedTree.fsi b/src/Compiler/TypedTree/TypedTree.fsi index 0cd4bfd2305..7fbe446641a 100644 --- a/src/Compiler/TypedTree/TypedTree.fsi +++ b/src/Compiler/TypedTree/TypedTree.fsi @@ -1791,6 +1791,8 @@ type TraitConstraintInfo = /// Get or set the solution of the member constraint during inference member Solution: TraitConstraintSln option with get, set + member CloneWithFreshSolution: unit -> TraitConstraintInfo + /// The member kind is irrelevant to the logical properties of a trait. However it adjusts /// the extension property MemberDisplayNameCore member WithMemberKind: SynMemberKind -> TraitConstraintInfo diff --git a/src/Compiler/TypedTree/TypedTreeOps.Remap.fs b/src/Compiler/TypedTree/TypedTreeOps.Remap.fs index b598accceff..4b82ad11086 100644 --- a/src/Compiler/TypedTree/TypedTreeOps.Remap.fs +++ b/src/Compiler/TypedTree/TypedTreeOps.Remap.fs @@ -497,6 +497,19 @@ module internal TypeRemapping = let copySlotSig ss = remapSlotSig (fun _ -> []) Remap.Empty ss + /// Decouple SRTP constraint solution ref cells on typars from any shared expression-tree nodes. + /// In FSI, codegen mutates shared TTrait solution cells after typechecking; decoupling at + /// generalization prevents stale solutions from bleeding into subsequent submissions. See #12386. + let decoupleTraitSolutions (typars: Typars) = + for tp in typars do + tp.SetConstraints( + tp.Constraints + |> List.map (fun cx -> + match cx with + | TyparConstraint.MayResolveMember(traitInfo, m) -> + TyparConstraint.MayResolveMember(traitInfo.CloneWithFreshSolution(), m) + | c -> c)) + let mkTyparToTyparRenaming tpsorig tps = let tinst = generalizeTypars tps mkTyparInst tpsorig tinst, tinst diff --git a/src/Compiler/TypedTree/TypedTreeOps.Remap.fsi b/src/Compiler/TypedTree/TypedTreeOps.Remap.fsi index 090d07beeb1..04cc615a670 100644 --- a/src/Compiler/TypedTree/TypedTreeOps.Remap.fsi +++ b/src/Compiler/TypedTree/TypedTreeOps.Remap.fsi @@ -193,6 +193,9 @@ module internal TypeRemapping = /// Copy a method slot signature, including new generic type parameters if the slot signature represents a generic method val copySlotSig: SlotSig -> SlotSig + /// Decouple SRTP constraint solution ref cells on typars from shared expression-tree nodes. + val decoupleTraitSolutions: Typars -> unit + val mkTyparToTyparRenaming: Typars -> Typars -> TyparInstantiation * TTypes val mkTyconInst: Tycon -> TypeInst -> TyparInstantiation diff --git a/tests/FSharp.Compiler.ComponentTests/Scripting/Interactive.fs b/tests/FSharp.Compiler.ComponentTests/Scripting/Interactive.fs index ea7e3fbe312..0d597f182e2 100644 --- a/tests/FSharp.Compiler.ComponentTests/Scripting/Interactive.fs +++ b/tests/FSharp.Compiler.ComponentTests/Scripting/Interactive.fs @@ -153,6 +153,76 @@ module MultiEmit = """if B.v <> 9.3 then failwith $"9: Failed {A.v} <> 9.3" """ |] |> Seq.iter(fun item -> item |> scriptIt) + // https://github.com/dotnet/fsharp/issues/12386 + // The bug manifests when the SRTP-constrained inline function is defined in one FSI submission + // and called in a separate submission. Single-unit compilation works fine. + // When a type has 2+ specific overloads plus a generic catch-all for operator ($), the SRTP + // constraint stays unresolved across submissions, causing value restriction (FS0030) or wrong + // runtime dispatch (NullReferenceException). Works fine within a single submission and in + // multi-file compiled projects. + [] + [] + [] + let ``Issue 12386 - SRTP trait call resolves correct overload across FSI submissions`` (useMultiEmit) = + let args: string array = [| if useMultiEmit then "--multiemit+" else "--multiemit-" |] + use session = new FSharpScript(additionalArgs = args) + + // Submission 1: Define type with overloaded ($) and an inline function using SRTP + session.Eval( + """ +type A = A with + static member ($) (A, a: float ) = 0.0 + static member ($) (A, a: decimal) = 0M + static member ($) (A, a: 't ) = 0 + +let inline call x = ($) A x +""" + ) + |> ignoreValue + + // Submission 2: Call with float - should resolve to the float overload, not the generic one + let result = session.Eval("call 42.") |> getValue + let fsiVal = result.Value + Assert.Equal(typeof, fsiVal.ReflectionType) + Assert.Equal(0.0, fsiVal.ReflectionValue :?> float) + + // Submission 3: Call with decimal - should resolve to the decimal overload + let result2 = session.Eval("call 42M") |> getValue + let fsiVal2 = result2.Value + Assert.Equal(typeof, fsiVal2.ReflectionType) + Assert.Equal(0M, fsiVal2.ReflectionValue :?> decimal) + + // Same scenario as Issue 12386 but via compiled cross-project reference (not FSI). + // This verifies whether the bug is FSI-specific or also affects project references. + [] + let ``Issue 12386 - SRTP trait call resolves correct overload across project references`` () = + let lib = + FSharp + """ +namespace Lib +type A = A with + static member ($) (A, a: float ) = 0.0 + static member ($) (A, a: decimal) = 0M + static member ($) (A, a: 't ) = 0 + +module Calls = + let inline call x = ($) A x +""" + |> asLibrary + + FSharp + """ +module App +open Lib.Calls + +let result = call 42. +if result <> 0.0 then failwithf "Expected 0.0 but got %A" result +""" + |> withReferences [ lib ] + |> asExe + |> compileExeAndRun + |> shouldSucceed + [] let ``Version directive displays version and environment info``() = Fsx """ From 0dfa2720140263961d9ab89da0e264183296023b Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Tue, 24 Mar 2026 11:28:02 +0100 Subject: [PATCH 2/3] Add regression test for #12386: SRTP trait call correct overload resolution Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ConstraintSolver/MemberConstraints.fs | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/FSharp.Compiler.ComponentTests/ConstraintSolver/MemberConstraints.fs b/tests/FSharp.Compiler.ComponentTests/ConstraintSolver/MemberConstraints.fs index 9e04bc75dfc..d9b31531d15 100644 --- a/tests/FSharp.Compiler.ComponentTests/ConstraintSolver/MemberConstraints.fs +++ b/tests/FSharp.Compiler.ComponentTests/ConstraintSolver/MemberConstraints.fs @@ -114,6 +114,34 @@ ignore ["1" .. "42"] |> withSingleDiagnostic (Error 1, Line 2, Col 9, Line 2, Col 12, "The type 'string' does not support the operator 'op_Range'") + // https://github.com/dotnet/fsharp/issues/12386 + [] + let ``Issue 12386 - SRTP trait call should resolve correct overload at runtime`` () = + FSharp + """ +type A = + | A + static member ($) (A, _a: float) = 0.0 + static member ($) (A, _a: decimal) = 0M + static member ($) (A, _a: 't) = 0 + +let inline call x = ($) A x + +[] +let main _ = + let resultFloat = call 42.0 + let resultDecimal = call 42M + let resultInt = call 42 + if resultFloat <> 0.0 then failwithf "Expected 0.0 but got %A" resultFloat + if resultDecimal <> 0M then failwithf "Expected 0M but got %A" resultDecimal + if resultInt <> 0 then failwithf "Expected 0 but got %A" resultInt + printfn "All SRTP overload resolutions correct" + 0 + """ + |> asExe + |> compileExeAndRun + |> shouldSucceed + // https://github.com/dotnet/fsharp/issues/6648 [] let ``Issue 6648 - DU of DUs with inline static members should compile`` () = From 00df6ea2fb641d74504db1ee75c5d7e726811ec8 Mon Sep 17 00:00:00 2001 From: Tomas Grosup Date: Tue, 14 Apr 2026 11:21:26 +0200 Subject: [PATCH 3/3] Fix fantomas formatting in TypedTreeOps.Remap.fs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/Compiler/TypedTree/TypedTreeOps.Remap.fs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Compiler/TypedTree/TypedTreeOps.Remap.fs b/src/Compiler/TypedTree/TypedTreeOps.Remap.fs index 4b82ad11086..605b189ce17 100644 --- a/src/Compiler/TypedTree/TypedTreeOps.Remap.fs +++ b/src/Compiler/TypedTree/TypedTreeOps.Remap.fs @@ -508,7 +508,8 @@ module internal TypeRemapping = match cx with | TyparConstraint.MayResolveMember(traitInfo, m) -> TyparConstraint.MayResolveMember(traitInfo.CloneWithFreshSolution(), m) - | c -> c)) + | c -> c) + ) let mkTyparToTyparRenaming tpsorig tps = let tinst = generalizeTypars tps