Repository navigation
Description
Description
When a debugger calls a method through ICorDebugEval and passes a ref struct by value, the runtime copies the struct into native memory outside the GC heap and reports its byref fields to the GC from a ProtectValueClassFrame (funceval.cpp#L1180-L1210, funceval.cpp#L3553, frames.cpp#L1471-L1487). The reporting goes through ReportPointersFromValueType and ByRefPointerOffsetsReporter (siginfo.cpp#L5170-L5269), which reports one slot per ref field, by offset, with no de-duplication.
With [StructLayout(LayoutKind.Explicit)], two ref fields (or a ref field and a nested byref-like field such as Span<T>) can legally share an offset. The same physical slot is then handed to the GC callback twice. GCHeap::Relocate has no "already relocated" check (interface.cpp#L1161, interface.cpp#L1226-L1230), so during the relocate phase of a compacting GC the second call treats the already-relocated pointer as an old address and moves it again (gc_heap::relocate_address, relocate_compact.cpp#L482, looks up the brick table for whatever address it is given). The byref ends up pointing at an unrelated address.
The runtime already treats this as an invariant for JIT'd frames: CheckDoubleReporting says "Double reporting is not allowed unless the reference is pinned, since it would result in incorrect updating of a reference in the slot in case the GC moves the object." That check only sees slots enumerated from GC info, not the direct callback calls made by ReportPointersFromValueType. Object-reference fields appear not to have the problem, because their offsets come from the GCDesc series rather than a per-field walk (not tested).
Impact: the evaluated method reads or writes through a wrong interior pointer, which can produce wrong values, heap corruption, or a crash in the debuggee. It needs all of the following:
- a debugger func-eval (
ICorDebugEval), not ordinary managed code; - a by-value argument whose type is a byref-like struct with two
reffields on the same slot, for example an explicit-layout union of twoSpan<T>; - a type the ABI passes by implicit reference, which is where the copy and
ProtectValueClassFramepath is used (for example, larger than 8 bytes on Windows x64); - a compacting GC while the evaluated call is running.
Reproduction Steps
-
Create a console project:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net11.0</TargetFramework> <Nullable>enable</Nullable> </PropertyGroup> </Project>
using System; using System.Diagnostics; using System.Runtime.InteropServices; // Both ref fields live at offset 0, so they alias one pointer-sized slot. // The extra 8 bytes make the struct 16 bytes, which Windows x64 passes by implicit reference. [StructLayout(LayoutKind.Explicit)] public ref struct AliasedRefs { [FieldOffset(0)] public ref int First; [FieldOffset(0)] public ref int Second; [FieldOffset(8)] public long Tag; } public static class Program { private static byte[]? s_dead; private static byte[]? s_live; public static int Main() { int[] target = [42]; AliasedRefs view = default; view.First = ref target[0]; view.Second = ref target[0]; if (Debugger.IsAttached) { Debugger.Break(); // func-eval Program.Consume(view) here } else { Console.WriteLine(Consume(view) == 0 ? "OK" : "FAILED"); } GC.KeepAlive(target); return 0; } // Returns 0 if every compacting GC left both aliases pointing at the array they were set to, // otherwise the 1-based iteration at which they stopped doing so. public static int Consume(AliasedRefs view) { for (int i = 0; i < 64; i++) { // A dead hole and a large live array in front of the new target make the compacting GC slide it down. s_dead = new byte[1_000 + 16 * i]; s_dead = null; s_live = new byte[40_000]; int[]? fresh = [1000 + i]; view.First = ref fresh[0]; view.Second = ref fresh[0]; fresh = null; // only the byref keeps the array alive GC.Collect(i % 3, GCCollectionMode.Forced, blocking: true, compacting: true); if (view.First != 1000 + i || view.Second != 1000 + i) { return i + 1; } } return 0; } }
-
Build it with
dotnet build -c Debugand run it without a debugger. It printsOK, so the loop is correct when the struct is an ordinary by-value argument. -
Launch it under any
ICorDebugclient so thatDebugger.IsAttachedistrue, and let it run to theDebugger.Break()inMain. Then:- Get the local
viewfrom theMainframe (ICorDebugILFrame::GetLocalVariable). - Create an eval (
ICorDebugThread::CreateEval) and callProgram.Consumewithviewas its only argument (ICorDebugEval::CallFunction(pConsume, 1, &pViewValue)). ICorDebugController::Continue, and onICorDebugManagedCallback::EvalCompleteread the result withICorDebugEval::GetResult(anint).
Run the debuggee with the default GC and again with
DOTNET_GCName=clrgc.dll(the standalone segments GC that ships with the runtime); both fail, at different iterations. - Get the local
Controls, a realistic variant, and a diagnostic variant
Same Consume loop with different parameter types, func-evaluated the same way on the same build:
| Parameter type | Default GC (regions) | DOTNET_GCName=clrgc.dll |
|---|---|---|
AliasedRefs (above) |
2 | 1 |
DisjointRefs (ref int at offsets 0 and 8, 16 bytes) |
0 | 0 |
SingleRef (ref int + long, 16 bytes) |
0 | 0 |
SpanUnion (below) |
2 | 1 |
DisjointRefs and SingleRef pass, so it is specifically the same slot being reported twice, not by-value ref structs in func-eval in general.
[StructLayout(LayoutKind.Explicit)]
public ref struct SpanUnion
{
[FieldOffset(0)] public Span<int> A;
[FieldOffset(0)] public Span<int> B;
}For SpanUnion the loop assigns view.A = fresh; and checks view.A[0] and view.B[0].
A diagnostic variant also keeps an ordinary int[] keep alive, so the correct post-GC address is known (it is relocated once, by the frame's GC info), and records Unsafe.AsPointer(ref keep[0]) before and after the GC together with Unsafe.AsPointer(ref view.First) after it. At the first failing iteration:
| GC | address before | expected after (keep) |
observed after (view.First) |
correct shift | extra shift |
|---|---|---|---|---|---|
| regions | 2877531661032 | 2877527466192 | 2877523271432 | 4,194,840 | 4,194,760 |
clrgc.dll |
2199304714728 | 2199304314256 | 2199304142088 | 400,472 | 172,168 |
In both cases the observed address is the correctly relocated address moved again by a second, different relocation distance, which is what a second relocate_address call on the already-relocated pointer would produce.
Expected behavior
Consume(view) returns 0, as it does when called directly without a debugger.
Actual behavior
The func-eval returns a non-zero iteration number (the first iteration at which a GC left the aliased refs pointing at the wrong place):
| GC | Result |
|---|---|
| Default (regions) | 2 (first gen1 GC) |
DOTNET_GCName=clrgc.dll (segments) |
1 (first gen0 GC) |
Both results were stable across 5 runs per configuration. The second relocation is a no-op when the already-relocated address is outside any moving plug of the condemned generations, so whether and when this shows up depends on the GC configuration and the heap layout.
Regression?
Probably not. The by-offset byref reporter has existed since ref fields were added (#64422, .NET 7), so this has likely always been reachable for overlapping layouts. It has not been bisected.
Known Workarounds
Avoid func-evaluating methods that take such a struct by value. Passing it by ref/in should avoid the by-value copy that goes through ProtectValueClassFrame (the copy is made only when the argument is not already a byref), but that was not tested.
Configuration
- Windows x64.
- Checked CoreCLR whose binaries are stamped with
maincommita28a1f81e3e712071729416783076dc21b2da40d(2026-09-24), run withcorerunfrom a Core_Root. The debuggee targetsnet11.0and was built in Debug. - The scanner (
ByRefPointerOffsetsReporter,ReportPointersFromValueType,ReportPointersFromValueTypeArg) is byte-identical at8e6821d2d9120795d68b6ebbcc47c9c7079de361(the commit used for the links above), and the by-value copy plusProtectValueClassFramepath is still present there.funceval.cpphas changed between the two (for example #133304). - Debugger: an
ICorDebugclient that passes the local'sICorDebugValueas a by-value argument toICorDebugEval.
Other information
Suggested fix. When reporting the byrefs of an explicit-layout byref-like type, skip a ref slot if an earlier overlapping field (in declaration order) already reports exactly that slot. In ByRefPointerOffsetsReporter, Find(PTR_MethodTable, ...) can carry the chain of enclosing types for which EEClass::HasOverlaidField() is true, and Report can re-run the earlier fields' search with a matching callback before calling the real callback. A fix along these lines is in progress as part of the managed func-eval work (PR to be linked).
With such a fix in a branch build, the minimal program above returns 0 in both GC configurations, and the diagnostic variant shows observed after == expected after. Caveat: the branch builds tried also change how func-eval passes by-value arguments, so those runs do not isolate the scanner change on their own; the attribution above rests on the controls, the double-shift numbers, and the code.
Other callers of the same scanner (by reading the code, not tested):
- On
mainafter #133986, func-eval results of byref-like types are kept inExternalMemoryHandlememory (funceval.cpp#L2817-L2820, funceval.cpp#L2955-L2960), which is scanned with the sameReportPointersFromValueType(externalmemoryhandle.cpp#L91-L103). A func-eval that returns such a struct would be expected to be affected by every compacting GC for as long as the result is retained. MetaSig::GcScanRootsusesReportPointersFromValueTypeArgfor by-value struct arguments in transition frames (TransitionFrame::PromoteCallerStack). Whether an aliased ref struct can reach that path from ordinary managed code, without a debugger, was not established. One attempt to construct such a case (an 8-byte aliased ref struct argument pending in a prestub frame, with a hardware watchpoint on the argument slot) did not show corruption, but the only write appeared to come from the JIT GC-info path rather than fromPromoteCallerStack, so it is not evidence either way.
Not verified: Release builds of the runtime, ARM64, Linux/macOS, servicing branches, Visual Studio or VS Code evaluators (they may not pass a ref struct local by value), and a stock build of today's main.
Note
This issue was drafted with GitHub Copilot (AI-generated content). The repro was run and the numbers above come from those runs, but please review it before relying on it.
Activity
dotnet-policy-service commentedon Oct 5, 2026
Tagging subscribers to this area: @steveisok, @tommcdon, @dotnet/dotnet-diag
See info in area-owners.md if you want to be subscribed.
jkotas commentedon Oct 5, 2026
This is a problem for all situations where ReportPointersFromValueType gets used (e.g. when we are inside a prestub of a method and ref struct with overlapping fields is passed as an argument by value).
Fix double GC relocation of overlapping byrefs (dotnet#135890)