let inline with SRTP capturing an enclosing local emits invalid IL under --optimize-
Status: already fixed upstream
This is already fixed in dotnet/fsharp main and needs no new work:
- Introduced by #19548 "Debugger: prefer no
inlining in non-optimized builds" (merged 2026-07-02), which added the <name>__debug@N
helper. First shipped in SDK 10.0.400.
- Reported as #20063 "Non-optimized inlining
regression" (2026-07-14, closed).
- Fixed by #20089 "Optimizer: fix accessing
captured values when skipping inlining" (merged 2026-08-12 into main).
The fix lifts the captured values into a leading argument group in Optimizer.fs:
// A static method would resolve values captured from the enclosing method against the caller's
// storage, so lift them into a leading argument group. The closure form captures them itself.
let specLambdaRFvs = freeInExpr CollectLocals specLambdaR
let capturedVals =
specLambdaRFvs.FreeLocals
|> Zset.elements
|> List.filter (fun v -> not v.IsCompiledAsTopLevel)
let capturedArgGroups = if List.isEmpty capturedVals then [] else [ capturedVals ]
It ships with baselines covering all three cases below — SRTP 30 - Capture of enclosing local,
SRTP 34 - Capture of enclosing inline function parameter, plus mutable-local, this, and
nested-closure variants.
The fix is not in any released SDK yet (10.0.400 still fails). The remainder of this document is
the original analysis.
Summary
When a local let inline function (a) has its type resolved by SRTP and (b) captures a
local or parameter of the enclosing method, the compiler emits a static helper method
<name>__debug@<line>. The body of that helper is copied from the enclosing method with the
enclosing frame's ldloc/ldarg slot indices intact, but the helper is a separate method that
receives only the inline function's own arguments. The captured value is therefore read from the
wrong slot of the wrong frame.
Depending on what happens to sit at that slot index in the helper, the result is either:
System.InvalidProgramException — the slot does not exist in the helper, or
- a silently wrong answer — the slot exists but holds something else.
Only builds with --optimize- (i.e. Optimize=false, the Debug default) are affected. This is
the silent-wrong-answer part that makes it more than a crash bug: the same source produces
different results in Debug and Release.
Repro
FSharpInlineBug/ in this folder is a standalone dotnet new console -lang "F#" project.
dotnet run --project FSharpInlineBug -c Debug # fails
dotnet run --project FSharpInlineBug -c Release # correct
Smallest single case:
let f () =
let n = 3
let inline scale x = int x * n // `int x` makes `scale` SRTP-resolved; `n` is captured
scale 10
printfn "%d" (f ()) // expected 30
Expected: 30
Actual (Debug): System.InvalidProgramException: Common Language Runtime detected an invalid program.
at <scale>__debug@4(Int32 x)
Observed output of the repro project (Debug)
case2 (captured float local): expected 8, actual -2147483648 -- WRONG RESULT
case3 (captured parameter): expected 30, actual 100 -- WRONG RESULT
case1 (captured int local): expected 30, actual InvalidProgramException
With Optimize=true all three print OK.
Generated IL
Case 1 — captured int local → invalid IL
let f () =
let n = 3
let inline scale x = int x * n
scale 10
.method public static int32 f () cil managed
{
.locals init ([0] int32, [1] object)
IL_0000: ldc.i4.3
IL_0001: stloc.0 // n -> slot 0 of f
IL_0002: ldloc.0
IL_0003: newobj instance void Min/scale@4::.ctor(int32) // closure built...
IL_0008: stloc.1 // ...stored, and never used
IL_0009: ldc.i4.s 10
IL_000b: call int32 Min::'<scale>__debug@5'(int32) // only `x` is passed
IL_0010: ret
}
.method public static int32 '<scale>__debug@5' (int32 x) cil managed
{
// Header size: 1 --> tiny format: NO locals signature at all
.maxstack 8
IL_0000: ldarg.0
IL_0001: ldloc.0 // <-- `n`, still addressed as slot 0 of f's frame. Invalid here.
IL_0002: mul
IL_0003: ret
}
ldloc.0 in a method that declares no locals is unverifiable, hence InvalidProgramException.
Note that the compiler does build the closure scale@4 capturing n in the enclosing method —
it is stored into a local and then dropped, so the capture is known at the point where the call to
the helper is emitted.
Case 2 — captured float local → silent wrong answer
let dpi = 120.0
let inline scale x = float x * 96.0 / dpi |> int
.method public static int32 '<scale>__debug@5' (int32 x) cil managed
{
.locals init ([0] float64) // the helper's own temp for the `|> int` pipe
IL_0000: ldarg.0
IL_0001: conv.r8
IL_0002: ldc.r8 96
IL_000b: mul
IL_000c: ldloc.0 // <-- meant to be `dpi`; actually the uninitialised temp = 0.0
IL_000d: div // 960.0 / 0.0 = infinity
IL_000e: stloc.0
IL_000f: ldloc.0
IL_0010: conv.i4
IL_0011: ret
}
Here slot 0 does exist, so the IL is verifiable and no exception is raised — it just computes
with 0.0 instead of 120.0. The result of conv.i4 on infinity is Int32.MinValue or
Int32.MaxValue depending on the platform.
Case 3 — captured parameter → silent wrong answer
let f (n: int) =
let inline scale x = int x * n
scale 10
.method public static int32 '<scale>__debug@4' (int32 x) cil managed
{
IL_0000: ldarg.0
IL_0001: ldarg.0 // <-- `n` was arg 0 of f; arg 0 of the helper is `x`
IL_0002: mul // computes x * x = 100 instead of 10 * 3 = 30
IL_0003: ret
}
This shows the same slot-index leak applies to argument slots, not just locals.
Conditions
All three of the following are required; removing any one makes the code compile and run
correctly:
- The local function is marked
inline.
- Its signature is resolved by SRTP (in the repro, via
int x / float x, i.e.
op_Explicit). An ordinary generic inline function is fine — no __debug@ helper is emitted
for it at all.
- It captures a local or parameter of the enclosing method. Capturing only module-level
values is fine.
And the build must use --optimize-.
Variants tested
| Variant |
Result |
inline, SRTP, captured local int |
InvalidProgramException |
inline, SRTP, captured local float |
Wrong result |
inline, SRTP, captured enclosing parameter |
Wrong result |
Same code without inline |
correct |
inline, argument type-annotated (no SRTP) |
correct |
inline, no arguments |
correct |
inline, generic but not SRTP (pick (x: 'a) (y: 'a)) |
correct — no __debug@ helper emitted |
inline, SRTP, no captured value |
correct |
Captured value is a module-level let |
correct |
Any of the above with Optimize=true |
correct |
Run as a script under dotnet fsi |
correct |
Affected versions
Reproduced on Windows 11 (x64), each SDK pinned via a global.json in the build directory with
"rollForward": "disable" (confirmed with dotnet --version per directory), clean build outputs:
| SDK |
F# compiler |
Debug |
Release |
__debug@ helper emitted |
| 8.0.413 |
F# 8.0 (12.8.403.0) |
ok |
ok |
no |
| 9.0.306 |
F# 9.0 (13.9.303.0) |
ok |
ok |
no |
| 10.0.204 |
F# 10.0 (15.2.204.0) |
ok |
ok |
no |
| 10.0.400 |
F# 10.0 (15.2.400.0) |
fails |
ok |
yes |
This is a recent regression, not a longstanding bug. The <name>__debug@N helper is only emitted
by SDK 10.0.400; earlier compilers do not generate it and are unaffected.
Optimize is the deciding switch, not debug info; DebugType makes no difference:
|
DebugType=portable |
DebugType=none |
Optimize=false |
fails |
fails |
Optimize=true |
ok |
ok |
Workarounds
- Remove
inline from the local function.
- Annotate the argument type so the function is no longer SRTP-resolved
(let inline scale (x: int) = ...).
- Pass the captured value as an argument instead of capturing it.
Original context
Found in a WPF/Excel interop callback, where the inline helper scaled a rectangle by a captured
DPI local:
let dpi = PerMonitorDPIHelper.GetDpiForWpfCoordinates xlWindowInfo.HWND
let wpfRect =
let inline scale x = float x * 96.0 / dpi |> int
Drawing.Rectangle(scale rect.X, scale rect.Y, scale rect.Width, scale rect.Height)
This threw InvalidProgramException at runtime in Debug builds and was fixed by dropping
inline.
let inlinewith SRTP capturing an enclosing local emits invalid IL under--optimize-Status: already fixed upstream
This is already fixed in
dotnet/fsharpmainand needs no new work:inlining in non-optimized builds" (merged 2026-07-02), which added the
<name>__debug@Nhelper. First shipped in SDK 10.0.400.
regression" (2026-07-14, closed).
captured values when skipping inlining" (merged 2026-08-12 into
main).The fix lifts the captured values into a leading argument group in
Optimizer.fs:It ships with baselines covering all three cases below —
SRTP 30 - Capture of enclosing local,SRTP 34 - Capture of enclosing inline function parameter, plus mutable-local,this, andnested-closure variants.
The fix is not in any released SDK yet (10.0.400 still fails). The remainder of this document is
the original analysis.
Summary
When a local
let inlinefunction (a) has its type resolved by SRTP and (b) captures alocal or parameter of the enclosing method, the compiler emits a static helper method
<name>__debug@<line>. The body of that helper is copied from the enclosing method with theenclosing frame's
ldloc/ldargslot indices intact, but the helper is a separate method thatreceives only the inline function's own arguments. The captured value is therefore read from the
wrong slot of the wrong frame.
Depending on what happens to sit at that slot index in the helper, the result is either:
System.InvalidProgramException— the slot does not exist in the helper, orOnly builds with
--optimize-(i.e.Optimize=false, theDebugdefault) are affected. This isthe silent-wrong-answer part that makes it more than a crash bug: the same source produces
different results in
DebugandRelease.Repro
FSharpInlineBug/in this folder is a standalonedotnet new console -lang "F#"project.Smallest single case:
Expected:
30Actual (Debug):
System.InvalidProgramException: Common Language Runtime detected an invalid program.at
<scale>__debug@4(Int32 x)Observed output of the repro project (Debug)
With
Optimize=trueall three printOK.Generated IL
Case 1 — captured
intlocal → invalid ILldloc.0in a method that declares no locals is unverifiable, henceInvalidProgramException.Note that the compiler does build the closure
scale@4capturingnin the enclosing method —it is stored into a local and then dropped, so the capture is known at the point where the call to
the helper is emitted.
Case 2 — captured
floatlocal → silent wrong answerHere slot 0 does exist, so the IL is verifiable and no exception is raised — it just computes
with
0.0instead of120.0. The result ofconv.i4on infinity isInt32.MinValueorInt32.MaxValuedepending on the platform.Case 3 — captured parameter → silent wrong answer
This shows the same slot-index leak applies to argument slots, not just locals.
Conditions
All three of the following are required; removing any one makes the code compile and run
correctly:
inline.int x/float x, i.e.op_Explicit). An ordinary genericinlinefunction is fine — no__debug@helper is emittedfor it at all.
values is fine.
And the build must use
--optimize-.Variants tested
inline, SRTP, captured localintinline, SRTP, captured localfloatinline, SRTP, captured enclosing parameterinlineinline, argument type-annotated (no SRTP)inline, no argumentsinline, generic but not SRTP (pick (x: 'a) (y: 'a))__debug@helper emittedinline, SRTP, no captured valueletOptimize=truedotnet fsiAffected versions
Reproduced on Windows 11 (x64), each SDK pinned via a
global.jsonin the build directory with"rollForward": "disable"(confirmed withdotnet --versionper directory), clean build outputs:__debug@helper emittedThis is a recent regression, not a longstanding bug. The
<name>__debug@Nhelper is only emittedby SDK 10.0.400; earlier compilers do not generate it and are unaffected.
Optimizeis the deciding switch, not debug info;DebugTypemakes no difference:DebugType=portableDebugType=noneOptimize=falseOptimize=trueWorkarounds
inlinefrom the local function.(
let inline scale (x: int) = ...).Original context
Found in a WPF/Excel interop callback, where the inline helper scaled a rectangle by a captured
DPI local:
This threw
InvalidProgramExceptionat runtime in Debug builds and was fixed by droppinginline.