Debug build emits invalid IL (stack underflow) for a non tail match whose last rule binds a parameterized active pattern (regression in SDK 10.0.4xx)

Ouverte
#20,910 0 commentaires 0 réactions 0 personnes assignées Voir sur GitHub

Les mainteneurs répondent en général sous 2 jours

Personne n'a encore pris cette issue.

Évaluation

Difficulté
4/5
Temps estimé
3-5 jours
Accessibilité débutants
15/100
Type d'issue
Bug
Clarté
Clairement spécifiée
Activité
Active
Stack technique
fsharp
Domaine
compilers

Piste de recherche

Start with the two-line repro in Debug (dotnet fsi --optimize- --debug+) and the IL dump, which shows a stray stloc at the start of the second rule. The payload points at src/fsharp/src/Compiler/CodeGen/IlxGen.fs and the debug-point spill in EmitDebugPoint (from #19877), so the relevant code is how the stack model is handled around match-rule joins. Done means the repro prints Some 5 in Debug with valid IL, and the Release output and the non-tail variants still work.

Rédigé par le modèle d'indexation à partir du texte de l'issue.

Description

Bug Needs-Triage Regression

In a Debug build (--optimize- with debug info on), SDK 10.0.402 emits invalid IL for a match that is not in tail position when its last rule binds the result of an active pattern that takes an argument. The branch for that rule begins with a stloc while the evaluation stack is empty, so the method throws System.InvalidProgramException: Common Language Runtime detected an invalid program. the first time it is called, whatever the input. Release builds are fine. SDK 10.0.203 and 9.0.317 compile the same code to valid IL.

Repro steps

  1. Save as repro.fsx:
let (|Add|) (k: int) (x: int) = x + k

let f (x: int) = Some (match x with Add 1 0 -> 0 | Add 2 y -> y)

try printfn "%A" (f 3) with e -> printfn "%s" (e.GetType().FullName)
  1. Run dotnet fsi --optimize- --debug+ repro.fsx with SDK 10.0.402.

The same code as a project: dotnet run -c Debug fails, dotnet run -c Release works.

FsDebugInvalidIl.fsproj:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
  </PropertyGroup>

  <ItemGroup>
    <Compile Include="Program.fs" />
  </ItemGroup>

</Project>

Program.fs:

module Program

open System
open System.Diagnostics
open System.Reflection
open System.Runtime.InteropServices

let (|Add|) (k: int) (x: int) = x + k

// The match is not in tail position, and its last rule binds the result of a parameterized total active pattern.
let f (x: int) = Some (match x with Add 1 0 -> 0 | Add 2 y -> y)

[<EntryPoint>]
let main _ =
    let debuggable = Assembly.GetExecutingAssembly().GetCustomAttribute<DebuggableAttribute>()
    let jitOptimizerDisabled = not (isNull debuggable) && debuggable.IsJITOptimizerDisabled
    printfn "Runtime: %s" RuntimeInformation.FrameworkDescription
    printfn "FSharp.Core: %O" (typeof<int option>.Assembly.GetName().Version)
    printfn "DebuggableAttribute.IsJITOptimizerDisabled: %b" jitOptimizerDisabled

    try
        printfn "f 3 = %A" (f 3)
        0
    with :? InvalidProgramException as e ->
        printfn "f 3 threw %s" (string e)
        1

The project targets net8.0 so that it also builds with SDK 9 for comparison. With <TargetFramework>net10.0</TargetFramework> it gives the same result on SDK 10.0.402.

Expected behavior

Some 5, in both Debug and Release.

Actual behavior

System.InvalidProgramException: Common Language Runtime detected an invalid program. when f is first called, in Debug only.

Compiler --optimize- --debug+ (Debug) --optimize+ (Release)
SDK 10.0.402 (F# Interactive 15.2.402.0, FSharp.Compiler.Service 43.12.402) InvalidProgramException Some 5
SDK 10.0.203 (F# Interactive 15.2.203.0, FSharp.Compiler.Service 43.12.203) Some 5 Some 5
SDK 9.0.317 (F# Interactive 13.9.303.0, FSharp.Compiler.Service 43.9.303) Some 5 Some 5

The compiled project (dotnet run -c Debug / -c Release, SDK pinned by global.json with rollForward: disable, Windows x64) gives the same picture: built with SDK 10.0.402, Debug throws on .NET 8.0.32 (net8.0) and on .NET 10.0.13 (net10.0), and Release prints Some 5 on both; built with SDK 10.0.203 or 9.0.317 (net8.0, run on .NET 8.0.32), Debug and Release both print Some 5.

The compiler that ships inside Visual Studio 2026 18.11.0 (FSharp.Compiler.Service 43.13.101-rc1, F# Interactive 15.2.101.0 for F# 11.0) has it too: its own fsi.exe, which runs on .NET Framework, throws the same exception for repro.fsx with --optimize- --debug+ and prints Some 5 with --optimize+. Visual Studio 2022 17.14 (F# 9.0) does not.

On 10.0.402 it also fails with --optimize- alone, --debug:portable and --debug:full. It works with --optimize- --debug-, i.e. with optimizations off but no debug info.

The IL of f from SDK 10.0.402 with --optimize- --debug+ (decoded from the method body bytes; 45 bytes, four int32 locals):

IL_0000: nop
IL_0001: ldarg.0
IL_0002: stloc.0
IL_0003: ldc.i4.1
IL_0004: ldloc.0
IL_0005: call       |Add|(int32, int32)
IL_000a: stloc.1
IL_000b: ldloc.1
IL_000c: switch     (IL_0017)
IL_0015: br.s       IL_001b
IL_0017: ldc.i4.0                       // first rule: result 0
IL_0018: nop
IL_0019: br.s       IL_0027             // to the join with one value on the stack
IL_001b: stloc.3                        // second rule: the stack is EMPTY here (underflow)
IL_001c: ldloc.3
IL_001d: ldc.i4.2
IL_001e: ldloc.0
IL_001f: call       |Add|(int32, int32)
IL_0024: stloc.2                        // y
IL_0025: ldloc.2
IL_0026: nop
IL_0027: call       FSharpOption`1<int32>::Some(!0)
IL_002c: ret

SDK 10.0.203 and 9.0.317 emit the same method without the stloc.3 / ldloc.3 pair (43 bytes, three locals); that IL is valid.

The stray local always has the type of the match's result. With an argument of type string, an input of type int64, a pattern result of type float and a match result of type bool, it is a bool local:

let (|Scale|) (k: string) (x: int64) = float x * float k.Length
let f (x: int64) = Some (match x with Scale "a" 0.0 -> true | Scale "bb" y -> y > 1.0)
IL_001f: br.s       IL_003c             // first rule's result goes to the join
IL_0021: stloc.3                        // V_3 is bool; the stack is empty here
IL_0022: ldloc.3
IL_0023: ldstr      "bb"
IL_0028: ldloc.0
IL_0029: call       |Scale|(string, int64)
...
IL_003c: call       FSharpOption`1<bool>::Some(!0)

So the store and reload look like a debug-point spill: when the code for the last rule starts, the code generator's stack model still seems to hold the previous rule's result, which on the real stack only exists on the path that already branched to the join. Since #19877 ("Emit debug points at a stack-empty position"), EmitDebugPoint spills every entry of that model to a local and reloads it, which turns the stale entry into a real stloc from an empty stack. That would also explain why --debug- and tail position (where the first rule ends in ret) are not affected. The source of the 10.0.402 SDK (dotnet/dotnet a8dd0577c5) contains the #19877 change and the source of 10.0.203 (dotnet/dotnet c23858a6d8) does not, which matches the version boundary above.

What matters, from reducing the original code on 10.0.402 in Debug:

  • Fails: Some (match ...), match ... |> Some, let r = match ... in Some r, 1 + (match ...); a different parameterized active pattern in each rule; an unparameterized total active pattern compared with a constant in the first rule (Twice 0 -> 0 | Add 2 y -> y, with (|Twice|) x = x * 2); a parameterized partial active pattern in the first rule; an argument that is a literal, a local or a function value; result types int, string and bool; a binding that the body does not use; an unreachable | _ -> ... rule after the binding rule.
  • Works: the match in tail position; Add 2 _ (no binding) as the last rule; an unparameterized total active pattern in the last rule (Add 1 0 -> 0 | Neg y -> y, with (|Neg|) x = -x); a single rule; a first rule that is a constant, a variable with a guard, or an unparameterized partial active pattern; anything with --optimize+.

We first hit it in a small string parser: nested matches on parameterized active patterns, with the result piped into Some and the last rule binding a value. The two-line repro above has the same shape.

Workarounds, and why they don't really help

Code-level workarounds, for a method you already know is affected:

  • Make the last rule bind a plain variable and apply the active pattern inside it: | z -> match z with Add 2 y -> y, | z -> let (Add 2 y) = z in y, or | z -> (|Add|) 2 z.
  • Move the match into its own function, so that it is in tail position there: let pick x = match x with Add 1 0 -> 0 | Add 2 y -> y and Some (pick x).
  • Adding an unreachable | _ -> ... after the binding rule does not help.

These only help once you know where the shape is, and nothing tells you. The compiler gives no warning or error, the build succeeds, and the failure appears only when the affected method is first called in a Debug build, at runtime. In an existing codebase the only way to find every occurrence ahead of time is to run an IL verifier over the Debug output. We found ours after it failed in a deployed Debug build.

Build-level workarounds:

  • Build with optimizations on (Release). That doesn't help the Debug builds teams run locally, in CI and in deployed test environments, which is where this fails.
  • Use SDK 10.0.3xx or older, or 9.0.x. That means staying off the current .NET 10 SDK feature band. The compiler that ships inside Visual Studio 2026 18.11.0 has the bug too, so IDE builds are affected as well.

Servicing request

This is a regression within the .NET 10 SDK: 10.0.3xx compiles this code correctly and every 10.0.4xx SDK so far emits invalid IL for it. Invalid IL is a code generation correctness bug, and it can't be found before runtime without an IL verifier. Please consider fixing it in a 10.0.4xx SDK patch and in the compiler that ships with Visual Studio 2026, not only in main. The open PR #20885 fixes the raise variant of the same debug-point spill; this shape has no raise, so it would need its own fix or a broader one.

Related information

  • Related, same class: #20871 (SDK 10.0.402, Debug, raise with a record copy in an async) and #20564 (if inside raise), both stack underflows after a debug-point spill. The open PR #20885 fixes those by resetting the stack model around the fake join in the raise code path. This repro has no raise, so that change does not appear to cover it.
  • Not the same as #20289 (let inline with SRTP capturing a local, under --optimize-), which was fixed in SDK 10.0.401. This repro has no inline and fails on 10.0.402, which includes that fix.
  • Not run: SDK 10.0.400, 10.0.401, 10.0.403 and 10.0.3xx. In dotnet/dotnet, src/fsharp/src/Compiler/CodeGen/IlxGen.fs is byte-identical at the tags v10.0.400, v10.0.401, v10.0.402 and v10.0.403 and has no #19877 spill at v10.0.300 or v10.0.303, so every 10.0.4xx SDK so far is presumably affected and 10.0.3xx is not.
  • Operating system: Windows 11 x64 (10.0.26200).
  • .NET runtime: .NET 10.0.13 (F# Interactive on SDK 10.0.402). We hit it in a deployed Debug build of a web service, compiled in CI from the sdk:10.0 container image: one request returned HTTP 500 the first time a rarely used, debug-only input parser ran. None of our tests called that method, and the JIT only rejects a method when it is first called, so the build, the tests and the Release compile all passed.
  • Editing tools: command line only (dotnet fsi, dotnet run).
Langage dominant
F#
Étoiles
4.3k
Forks
884
Merge moyen
2 j 11 h
PR mergées (30 j)
73

Préparer son environnement

Ouvrir dans Codespaces

Lance le conteneur de développement du projet dans votre navigateur, avec votre propre compte GitHub.

Par où commencer

  1. Lisez l'issue en entier, puis le guide de contribution du projet.
  2. Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
  3. Forkez le dépôt et travaillez sur une branche.
  4. Ouvrez une pull request qui référence le numéro de l'issue.

Autres issues de dotnet/fsharp

Toutes les issues de dotnet/fsharp

Issues similaires

Plus d'issues Compilers

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.