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)

Abierto
#20,910 0 comentarios 0 reacciones 0 asignados Ver en GitHub

Los mantenedores suelen responder en 2 días

Nadie ha tomado este issue todavía.

Evaluación

Dificultad
4/5
Tiempo estimado
3-5 días
Aptitud para principiantes
15/100
Tipo de issue
Error
Claridad
Bien especificado
Estado de actividad
Activo
Stack tecnológico
fsharp
Área
compilers

Línea de trabajo

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.

Escrito por el modelo de indexación a partir del texto del issue.

Descripción

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).
Lenguaje dominante
F#
Estrellas
4.3k
Forks
884
Merge medio
2 d 11 h
PR fusionados (30 d)
73

Preparar el entorno

Abrir en Codespaces

Inicia el contenedor de desarrollo del proyecto en tu navegador, con tu propia cuenta de GitHub.

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Más de dotnet/fsharp

Todos los issues de dotnet/fsharp

Issues similares

Más issues de Compilers

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.