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)
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
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
- 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)
- Run
dotnet fsi --optimize- --debug+ repro.fsxwith 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 typesint,stringandbool; 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 -> yandSome (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,
raisewith a record copy in anasync) and #20564 (ifinsideraise), both stack underflows after a debug-point spill. The open PR #20885 fixes those by resetting the stack model around the fake join in theraisecode path. This repro has noraise, so that change does not appear to cover it. - Not the same as #20289 (
let inlinewith SRTP capturing a local, under--optimize-), which was fixed in SDK 10.0.401. This repro has noinlineand 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.fsis 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.0container 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
Inicia el contenedor de desarrollo del proyecto en tu navegador, con tu propia cuenta de GitHub.
- Sin Dockerfile ni archivo de Docker Compose
- Tiene una plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de dotnet/fsharp
-
Needs-Triage
Dificultad 1/5 Menos de una hora Aptitud para principiantes 95/100
Los mantenedores suelen responder en 2 días
-
Semantic classification cache for opened documents is never populated (written to the unopened-documents cache)Posiblemente ocupada @xperiandri la tomó hace 69 días. AbiertoNeeds-Triage
Dificultad 1/5 Menos de una hora Aptitud para principiantes 90/100
Los mantenedores suelen responder en 2 días
-
Needs-Triage
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
dotnet/fsharp#20265 · 1 comentario ·
Los mantenedores suelen responder en 2 días
-
Bug Needs-Triage
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
Los mantenedores suelen responder en 2 días
-
Needs-Triage
Dificultad 4/5 3-5 días Aptitud para principiantes 45/100
Los mantenedores suelen responder en 2 días
Todos los issues de dotnet/fsharp
Issues similares
-
from-agent
Dificultad 2/5 1-3 horas Aptitud para principiantes 62/100
-
code quality wasm backend
Dificultad 1/5 Menos de una hora Aptitud para principiantes 84/100
nanyx-lang/nanyx#930 ·
Los mantenedores suelen responder en 1 día
-
[mlir][math] math-expand-ops computes math.ctlz wrongly for widths that are not a power of twoAbiertomlir
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
llvm/llvm-project#241891 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
arrays bug intrinsic llvm medium priority wrong result
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
Los mantenedores suelen responder en 1 día
-
`lower_throw!` treats any function whose name contains `jl_error` etc. as a throw (kernel becomes a trap / segfault)Posiblemente ocupada @sasmith-14 la tomó hoy. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
JuliaGPU/GPUCompiler.jl#1088 ·
Los mantenedores suelen responder en 1 día