Skip to content

Debugger func-eval: a by-value ref struct with overlapping ref fields is reported to the GC twice, so a compacting GC relocates the byref twice #135245

Description

@jkoritzinsky

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 ref fields on the same slot, for example an explicit-layout union of two Span<T>;
  • a type the ABI passes by implicit reference, which is where the copy and ProtectValueClassFrame path is used (for example, larger than 8 bytes on Windows x64);
  • a compacting GC while the evaluated call is running.

Reproduction Steps

  1. 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;
        }
    }
  2. Build it with dotnet build -c Debug and run it without a debugger. It prints OK, so the loop is correct when the struct is an ordinary by-value argument.

  3. Launch it under any ICorDebug client so that Debugger.IsAttached is true, and let it run to the Debugger.Break() in Main. Then:

    1. Get the local view from the Main frame (ICorDebugILFrame::GetLocalVariable).
    2. Create an eval (ICorDebugThread::CreateEval) and call Program.Consume with view as its only argument (ICorDebugEval::CallFunction(pConsume, 1, &pViewValue)).
    3. ICorDebugController::Continue, and on ICorDebugManagedCallback::EvalComplete read the result with ICorDebugEval::GetResult (an int).

    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.

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 main commit a28a1f81e3e712071729416783076dc21b2da40d (2026-09-24), run with corerun from a Core_Root. The debuggee targets net11.0 and was built in Debug.
  • The scanner (ByRefPointerOffsetsReporter, ReportPointersFromValueType, ReportPointersFromValueTypeArg) is byte-identical at 8e6821d2d9120795d68b6ebbcc47c9c7079de361 (the commit used for the links above), and the by-value copy plus ProtectValueClassFrame path is still present there. funceval.cpp has changed between the two (for example #133304).
  • Debugger: an ICorDebug client that passes the local's ICorDebugValue as a by-value argument to ICorDebugEval.

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 main after #133986, func-eval results of byref-like types are kept in ExternalMemoryHandle memory (funceval.cpp#L2817-L2820, funceval.cpp#L2955-L2960), which is scanned with the same ReportPointersFromValueType (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::GcScanRoots uses ReportPointersFromValueTypeArg for 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 from PromoteCallerStack, 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

  1. dotnet-policy-service commented on Oct 5, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @steveisok, @tommcdon, @dotnet/dotnet-diag
    See info in area-owners.md if you want to be subscribed.

  2. jkotas commented on Oct 5, 2026

    @jkotas
    Member

    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).

  3. added a commit that references this issue on Oct 17, 2026
    6fe708a
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

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions