Skip to content

let inline with SRTP capturing an enclosing local emits invalid IL under --optimize- #20289

Description

@roboz0r

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:

  1. The local function is marked inline.
  2. 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.
  3. 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions