You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Since #19548inline functions are not inlined in debug builds, including those where inlining is not negotiable.
Repro steps
openSystemopenFSharp.NativeInterop#nowarn9// without inlining the returned Span points at a stack position that is no longer validlet inlinestackalloc<'awhen'a:unmanaged>length =
Span<'a>(NativePtr.stackalloc<'a> length |> NativePtr.toVoidPtr, length)let recclobber n =let mutablea= int64 n
let mutableb= a *2L
let mutablec= b *2L
let mutabled= c *2L
if n >0then clobber (n -1)+ int (a + b + c + d)else0letdemo()=letb= stackalloc<char>3
b.[0]<-'a'
b.[1]<-'b'
b.[2]<-'c'
clobber 64|> ignore
String b
letr= demo ()
printfn "expected: \"abc\" [97; 98; 99]"
printfn "actual: %A%A" r (r |> Seq.map int |> Seq.toList)
typeLoadMechanism=| NoSource
| BytesSource ofbyte[]letloadResource()=letmechanisms=[ NoSource; BytesSource [|1uy;2uy;3uy |]]// Closes over `mechanisms` from the enclosing scope instead of taking it as a parameter --let inlinemakeTypedCsvLoader loadStream mkResult =
mechanisms
|> Seq.choose (fun m ->letloadStream= loadStream >> Array.map int
match m with| NoSource -> None
| BytesSource bytes -> Some(loadStream bytes))|> Seq.tryHead
|> Option.map mkResult
makeTypedCsvLoader id id
[<EntryPoint>]letmain _ =letexpected= Some [|1;2;3|]letactual= loadResource ()
printfn "expected: %A" expected
printfn "actual: %A" actual
if actual = expected then
printfn "OK"0else
printfn "MISMATCH (unexpected on this compiler -- bug may be fixed)"1
With current VS2026 insider compiler, targetting net48, in debug, mechanisms end up being null rather than closing over the outer scope value.
Unhandled Exception: System.ArgumentNullException: Value cannot be null.
Parameter name: source
at Microsoft.FSharp.Collections.SeqModule.Choose[T,TResult](FSharpFunc2 chooser, IEnumerable1 source) in D:\a_work\1\s\src\FSharp.Core\seq.fs:line 777
at Program.__debug@69(FSharpFunc2 loadStream, FSharpFunc2 mkResult)
let inlinestackalloc n =
Span<char>(NativePtr.stackalloc<char> n |> NativePtr.toVoidPtr, n)letf()=letb= stackalloc 1
b.[0]<-'a'
String b
In addition to enabling the optimizations, here are two more workarounds:
use --always-inline+ in the project
mark the function with NoDynamicInvocation
There are several ways to tackle this. One way is to propagate force-inlining when an inline function uses any functions marked with NoDynamicInvocation. This would fix this case, but it would silently break the debugger improvements from #19548 which is not good.
Another way is to ask the users to mark such functions with NoDynamicInvocation explicitly: this way adds churn to the user codebases, but it makes the intent clear and communicates the debugging expectations to both users and IDEs. This can be done by adding a new compiler warning when a function not marked with NoDynamicInvocation uses other such functions.
There's a trade off in either option, so I'd like to hear more opinions.
@auduchinok, I’m concerned about the proposed solutions.
They require users to determine which methods need annotation and identify when runtime behavior is incorrect, making it difficult to determine when an attribute needs to be added.
This breaks backward compatibility of perfectly fine code and makes debug compiled code buggy.
My suggestions:
if there is a way to have a proper heuristic, the NoDynamicInvocation attribute would be added implicitly
when specialist users (those who know how to use inline as it were before this change) want their inline functions to be debuggable, and are aware of potential side-effects, they could annotate the specific functions that won't be inlined in debug builds, with an opt-in attribute
I feel this would be better user experience than hard to identify regressions in debug builds, however I understand that stepping into inline function, not working at all out of the box, is a concern; maybe the initial approach was too optimistic?
@smoothdeveloper These are valid concerns, thank you. These suggestions apply to relatively rare unverifiable code only, like the stackalloc example above, and all the rest inline cases should just work without any changes. The first option suggests to propagate this automatically indeed, so there would be no regression and no user action would be required. The second option suggests producing a warning, so users still wouldn't have to determine anything themselves. We could try to do both: propagate the attribute implicitly and suggest adding it via a warning.
It is annoying that you couldn't add the attribute to a local inline function though — you'd need to pull the function out into class/module scope in order to add the attribute.
you'd need to pull the function out into class/module scope in order to add the attribute.
In my usage of inline, I often use it for local functions, and they do close over symbols; sometimes to hide another function and supplying some of the parameters.
The first option suggests to propagate this automatically indeed, so there would be no regression and no user action would be required.
Sorry, I misunderstood that it would happen automatically. I'm for the implicit solution with no added warnings.
Could some of the byref analysis that we have in postinferencechecks be moved+used to build up the heuristics around implicitly adding NoDynamicInvocation ?
It would for sure help if more distinct examples are collected here 👍 .
I think we cannot accept the pls add an attribute if you see it resolution here - the original repro from @kerams might be very difficult to spot in a larger program, since it does not end with any program error. It just produces incorrect data.
Could some of the byref analysis that we have in postinferencechecks be moved+used to build up the heuristics around implicitly adding NoDynamicInvocation ?
@T-Gro Yes, this is what I have in mind. I'm on vacation right now, so will look into it next week 🙂
We independently hit this exact regression in a real production codebase, via the
same stackalloc helper shape (inline wrapper around NativePtr.stackalloc +
manual Span construction). Flagging because our environment differs from the
original report in a way that may raise the urgency: we saw this on SDK 10.0.400,
a shipped GA servicing release installed via plain winget install Microsoft.DotNet.SDK.10 -v 10.0.400 — not the 11.0.100-preview channel. Since #20242 merged to main on Aug 11 and 10.0.400 was already out, it looks like this
regression already reached real users on stable .NET 10, and the fix likely hasn't
been serviced back into the 10.0.4xx branch yet.
Confirmed side by side on the same machine (same underlying runtime, .NET 10.0.11, in every case — only the SDK-bundled compiler differs):
Also worth noting: our repro doesn't need the clobber() recursive-call trick from
the original report at all. A nested closure captured in the while loop's own
condition (capturing and pattern-matching an option-typed outer parameter), with
the stackalloc call wrapped in if ... then try ... finally, is sufficient on its
own to trigger it:
openSystemopenFSharp.NativeInterop#nowarn"9"let inlinestackalloc<'awhen'a:unmanaged>(length:int):Span<'a>=letp= NativePtr.stackalloc<'a> length |> NativePtr.toVoidPtr
Span<'a>(p, length)letdata:byte[]= Array.zeroCreate 60
data[40]<-80uy
data[41]<-75uy
data[42]<-3uy
data[43]<-4uy
letfindMarker(data:byte[],maxOffset:int option):int option =let mutableresult= None
let mutablei=0letbeforeMaxOffset()=match maxOffset with| None ->true| Some offset -> i < offset +4if data.Length >4thentryletbytes:Span<byte>= stackalloc 4while i < data.Length && result.IsNone && beforeMaxOffset ()do
bytes[0]<- bytes[1]
bytes[1]<- bytes[2]
bytes[2]<- bytes[3]
bytes[3]<- data[i]if bytes[0]=80uy && bytes[1]=75uy && bytes[2]=3uy && bytes[3]=4uy then
result <- Some(i -3)
i <- i +1finally()
result
[<EntryPoint>]letmain _ =
printfn "Runtime: %s" System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription
match findMarker (data, Some 60)with| Some pos -> printfn "FOUND at offset %d (expected 40)" pos
| None -> printfn "NOT FOUND (expected offset 40)"0
Expected/actual: FOUND at offset 40 on every SDK above except 10.0.400 Debug,
which prints NOT FOUND — the marker genuinely is at data[40..43], so this is
silent data corruption, not a crash.
Real-world impact for us: this broke a ZIP-header-scanning routine (locating PK\x03\x04 inside a proprietary container format) with no exception — just a
wrong answer — after picking up 10.0.400 on a build agent. Given this shape (no
diagnostic, Debug-only, silent), we'd treat this as a correctness issue worth
tracking until it's confirmed fixed on the 10.0.4xx servicing train specifically,
not just on main. Happy to help verify against a servicing build once one is
available.
Activity
T-Gro commentedon Jul 22, 2026
@auduchinok : Can you take a look please?
auduchinok commentedon Jul 22, 2026
Yes, thanks, it's on my list 🙂
smoothdeveloper commentedon Jul 26, 2026
Also hitting this with recent release of VS2026 insider. What is the plan to identify functions that can't be inlined?
In my case it is calling SRTP in a stack of inline functions; which provokes a runtime failure.
auduchinok commentedon Jul 26, 2026
Could you share a repro look into, please?
smoothdeveloper commentedon Jul 26, 2026
With current VS2026 insider compiler, targetting net48, in debug,
mechanismsend up being null rather than closing over the outer scope value.in release it works properly.
auduchinok commentedon Jul 28, 2026
A smaller repro @kerams's case is this:
In addition to enabling the optimizations, here are two more workarounds:
--always-inline+in the projectNoDynamicInvocationThere are several ways to tackle this. One way is to propagate force-inlining when an inline function uses any functions marked with
NoDynamicInvocation. This would fix this case, but it would silently break the debugger improvements from #19548 which is not good.Another way is to ask the users to mark such functions with
NoDynamicInvocationexplicitly: this way adds churn to the user codebases, but it makes the intent clear and communicates the debugging expectations to both users and IDEs. This can be done by adding a new compiler warning when a function not marked withNoDynamicInvocationuses other such functions.There's a trade off in either option, so I'd like to hear more opinions.
auduchinok commentedon Jul 28, 2026
@smoothdeveloper Thanks! It looks like a genuine bug. I'm looking into it.
smoothdeveloper commentedon Jul 31, 2026
@auduchinok, I’m concerned about the proposed solutions.
They require users to determine which methods need annotation and identify when runtime behavior is incorrect, making it difficult to determine when an attribute needs to be added.
This breaks backward compatibility of perfectly fine code and makes debug compiled code buggy.
My suggestions:
NoDynamicInvocationattribute would be added implicitlyI feel this would be better user experience than hard to identify regressions in debug builds, however I understand that stepping into inline function, not working at all out of the box, is a concern; maybe the initial approach was too optimistic?
auduchinok commentedon Jul 31, 2026
@smoothdeveloper These are valid concerns, thank you. These suggestions apply to relatively rare unverifiable code only, like the
stackallocexample above, and all the restinlinecases should just work without any changes. The first option suggests to propagate this automatically indeed, so there would be no regression and no user action would be required. The second option suggests producing a warning, so users still wouldn't have to determine anything themselves. We could try to do both: propagate the attribute implicitly and suggest adding it via a warning.brianrourkeboll commentedon Jul 31, 2026
It is annoying that you couldn't add the attribute to a local inline function though — you'd need to pull the function out into class/module scope in order to add the attribute.
smoothdeveloper commentedon Aug 1, 2026
In my usage of inline, I often use it for local functions, and they do close over symbols; sometimes to hide another function and supplying some of the parameters.
Sorry, I misunderstood that it would happen automatically. I'm for the implicit solution with no added warnings.
T-Gro commentedon Aug 3, 2026
@auduchinok :
Could some of the byref analysis that we have in postinferencechecks be moved+used to build up the heuristics around implicitly adding
NoDynamicInvocation?It would for sure help if more distinct examples are collected here 👍 .
I think we cannot accept the
pls add an attribute if you see itresolution here - the original repro from @kerams might be very difficult to spot in a larger program, since it does not end with any program error. It just produces incorrect data.auduchinok commentedon Aug 3, 2026
@T-Gro Yes, this is what I have in mind. I'm on vacation right now, so will look into it next week 🙂
let inlinewith SRTP capturing an enclosing local emits invalid IL under--optimize-#20289 as a duplicate of this issuechristian-clausen commentedon Aug 21, 2026
We independently hit this exact regression in a real production codebase, via the
same
stackallochelper shape (inlinewrapper aroundNativePtr.stackalloc+manual
Spanconstruction). Flagging because our environment differs from theoriginal report in a way that may raise the urgency: we saw this on SDK 10.0.400,
a shipped GA servicing release installed via plain
winget install Microsoft.DotNet.SDK.10 -v 10.0.400— not the11.0.100-previewchannel. Since#20242 merged to
mainon Aug 11 and 10.0.400 was already out, it looks like thisregression already reached real users on stable .NET 10, and the fix likely hasn't
been serviced back into the 10.0.4xx branch yet.
Confirmed side by side on the same machine (same underlying runtime,
.NET 10.0.11, in every case — only the SDK-bundled compiler differs):Also worth noting: our repro doesn't need the
clobber()recursive-call trick fromthe original report at all. A nested closure captured in the
whileloop's owncondition (capturing and pattern-matching an
option-typed outer parameter), withthe
stackalloccall wrapped inif ... then try ... finally, is sufficient on itsown to trigger it:
Expected/actual:
FOUND at offset 40on every SDK above except 10.0.400 Debug,which prints
NOT FOUND— the marker genuinely is atdata[40..43], so this issilent data corruption, not a crash.
Real-world impact for us: this broke a ZIP-header-scanning routine (locating
PK\x03\x04inside a proprietary container format) with no exception — just awrong answer — after picking up 10.0.400 on a build agent. Given this shape (no
diagnostic, Debug-only, silent), we'd treat this as a correctness issue worth
tracking until it's confirmed fixed on the 10.0.4xx servicing train specifically,
not just on
main. Happy to help verify against a servicing build once one isavailable.
Merge release/10.0.4xx into backport/dev18.11-to-10.0.4xx