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)
Mantenedores costumam responder em até 2 dias
Ninguém assumiu esta issue ainda.
Avaliação
- Dificuldade
- 4/5
- Tempo estimado
- 3-5 dias
- Facilidade para iniciantes
- 15/100
- Tipo de issue
- Bug
- Clareza
- Claramente especificada
- Status de atividade
- Ativa
- Stack de tecnologia
- fsharp
- Domínio
- compilers
Direção de pesquisa
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.
Escrita pelo modelo de indexação a partir do texto da issue.
Descrição
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).
- Linguagem predominante
- F#
- Estrelas
- 4.3k
- Forks
- 884
- Merge médio
- 2d 11h
- PRs com merge (30d)
- 73
Preparar o ambiente
Inicia o contêiner de desenvolvimento do projeto no navegador, com a sua própria conta do GitHub.
- Sem Dockerfile nem arquivo Docker Compose
- Tem um modelo de pull request
- Ler o guia de contribuição
Primeiros passos
- Leia a issue inteira e depois o guia de contribuição do projeto.
- Comente na issue dizendo que vai assumir — evita que duas pessoas façam o mesmo trabalho.
- Faça um fork do repositório e trabalhe em uma branch.
- Abra um pull request que referencie o número da issue.
Mais de dotnet/fsharp
-
Needs-Triage
Dificuldade 1/5 Menos de uma hora Facilidade para iniciantes 95/100
Mantenedores costumam responder em até 2 dias
-
Semantic classification cache for opened documents is never populated (written to the unopened-documents cache)Talvez já em andamento @xperiandri assumiu há 69 dias. AbertaNeeds-Triage
Dificuldade 1/5 Menos de uma hora Facilidade para iniciantes 90/100
Mantenedores costumam responder em até 2 dias
-
Needs-Triage
Dificuldade 2/5 1-3 horas Facilidade para iniciantes 78/100
dotnet/fsharp#20265 · 1 comentário ·
Mantenedores costumam responder em até 2 dias
-
Bug Needs-Triage
Dificuldade 2/5 1-3 horas Facilidade para iniciantes 78/100
Mantenedores costumam responder em até 2 dias
-
Needs-Triage
Dificuldade 4/5 3-5 dias Facilidade para iniciantes 45/100
Mantenedores costumam responder em até 2 dias
Todas as issues de dotnet/fsharp
Issues semelhantes
-
from-agent
Dificuldade 2/5 1-3 horas Facilidade para iniciantes 62/100
-
code quality wasm backend
Dificuldade 1/5 Menos de uma hora Facilidade para iniciantes 84/100
nanyx-lang/nanyx#930 ·
Mantenedores costumam responder em até 1 dia
-
[mlir][math] math-expand-ops computes math.ctlz wrongly for widths that are not a power of twoAbertamlir
Dificuldade 2/5 1-3 horas Facilidade para iniciantes 68/100
llvm/llvm-project#241891 · 1 comentário ·
Mantenedores costumam responder em até 1 dia
-
arrays bug intrinsic llvm medium priority wrong result
Dificuldade 2/5 1-3 horas Facilidade para iniciantes 75/100
Mantenedores costumam responder em até 1 dia
-
`lower_throw!` treats any function whose name contains `jl_error` etc. as a throw (kernel becomes a trap / segfault)Talvez já em andamento @sasmith-14 assumiu há 1 dia. Aberta
Dificuldade 2/5 1-3 horas Facilidade para iniciantes 75/100
JuliaGPU/GPUCompiler.jl#1088 ·
Mantenedores costumam responder em até 1 dia