Skip to content

Func-eval ref struct result: ExternalMemoryHandle doesn't keep a collectible LoaderAllocator alive (access violation in GC after unload) #135252

Description

@jkoritzinsky

Description

DebuggerExternalMemoryOwner (added by #133986) keeps the result of a func-eval that returns a byref-like value type (ref struct) in an interop-safe buffer and registers that buffer with the GC through ExternalMemoryHandle::Add(pMT, buffer, 0) (debugger.cpp#L1320-L1333; it is the only ExternalMemoryHandle::Add caller, reached from funceval.cpp#L2816-L2820 and funceval.cpp#L2955-L2960). The handle stores a raw MethodTable*, and on every GC ExternalMemoryHandle::GCScanRoot dereferences it (m_pMT->IsValueType(), then ReportPointersFromValueType(..., m_pMT, ...); externalmemoryhandle.cpp#L91-L121).

Nothing keeps that MethodTable, or more precisely its LoaderAllocator, alive. The value is a struct in native memory, so there is no object whose method table the GC could follow to the loader allocator, and the owner does not root the allocator either. If the ref struct type is collectible, for example a closed generic such as View<T> where T is a collectible type (its method table is allocated in T's collectible LoaderAllocator, even though View<T> is defined in a non-collectible assembly) or a ref struct from a collectible assembly, the allocator can be unloaded while the owner is still registered. The next GC then walks a freed MethodTable.

The owner can outlive the unload for as long as the debugger wants it to:

So a debugger that keeps the ICorDebugValue of the result across a resume leaves the handle registered while the debuggee runs freely, including unloading the collectible assembly.

Reproduction Steps

Windows x64. The repro is a tiny debuggee plus a minimal ICorDebug client (about 240 lines, using the public ClrDebug and Microsoft.Diagnostics.DbgShim.win-x64 packages).

  1. Build CoreCLR and the libraries from main in Release (see the note about Checked builds below) and generate a Core_Root:

    .\build.cmd clr+libs -rc release -lc release
    .\src\tests\build.cmd -GenerateLayoutOnly x64 Release
    $coreRoot = "$PWD\artifacts\tests\coreclr\windows.x64.Release\Tests\Core_Root"
  2. Create the two projects from the sources below (Debuggee\Debuggee.csproj, Debuggee\Program.cs, Driver\Driver.csproj, Driver\Program.cs) and build them (SDK 10.x):

    dotnet build Debuggee\Debuggee.csproj -c Release -o out\Debuggee
    dotnet build Driver\Driver.csproj -c Release -o out\Driver
  3. Run the driver. It launches the debuggee under the debugger using corerun.exe from the Core_Root. The last argument is a timeout in seconds, because after the crash the debuggee hangs (see below):

    out\Driver\Driver.exe $coreRoot\corerun.exe out\Debuggee\Debuggee.dll 30

What the repro does:

  1. The debuggee creates a RunAndCollect assembly with an empty class CollectiblePayload, then calls Debugger.Break(). Producer.Create<T>() returns a View<T> (a ref struct with a ref int field), and the closed type View<CollectiblePayload> lives in the collectible LoaderAllocator.
  2. The debugger (driver) takes the ICorDebugClass of CollectiblePayload from the LoadClass callback and func-evals Producer.Create<CollectiblePayload>(). It calls GetResult and keeps the returned ICorDebugValue and ICorDebugEval alive, then continues.
  3. The debuggee drops every reference to the collectible assembly (they only lived in a stack frame that has returned) and runs GC.Collect(); GC.WaitForPendingFinalizers(); ten times.

Controls (environment variables read by the driver):

  • REPRO_SKIP_EVAL=1: the driver doesn't func-eval at all.
  • REPRO_RELEASE_RESULT=1: the driver func-evals, but releases the result value and the eval (Marshal.FinalReleaseComObject) instead of retaining them.
Debuggee\Debuggee.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <PlatformTarget>x64</PlatformTarget>
    <Optimize>false</Optimize>
    <DebugType>portable</DebugType>
    <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
  </PropertyGroup>

</Project>
Debuggee\Program.cs
using System;
using System.Diagnostics;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.CompilerServices;

// A ref struct defined in a NON-collectible assembly. When it is closed over a collectible type argument, the
// closed type's MethodTable is allocated in that collectible type's LoaderAllocator.
public ref struct View<T>
{
    public ref int Number;
}

public static class Producer
{
    private static readonly int[] s_numbers = { 10, 20, 30 };

    // The method that the debugger func-evals. It returns a ref struct, so the runtime stores the result in
    // debugger-owned external memory that is registered with the GC through an ExternalMemoryHandle.
    public static View<T> Create<T>()
    {
        View<T> view = default;
        view.Number = ref s_numbers[1];
        return view;
    }
}

public static class Program
{
    private static void Log(string message) => Console.WriteLine($"[debuggee {Environment.ProcessId}] {message}");

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static Type CreateCollectibleType()
    {
        AssemblyBuilder assembly = AssemblyBuilder.DefineDynamicAssembly(
            new AssemblyName("CollectibleAssembly"), AssemblyBuilderAccess.RunAndCollect);
        ModuleBuilder module = assembly.DefineDynamicModule("CollectibleAssembly");
        return module.DefineType("CollectiblePayload", TypeAttributes.Public | TypeAttributes.Class).CreateType();
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    private static void Pause()
    {
        Log("break: the debugger now func-evals Producer.Create<CollectiblePayload>() and keeps the returned value");
        Debugger.Break();
    }

    // Every reference to the collectible assembly lives only in this frame, which has returned by the time
    // the garbage collections in Main run.
    [MethodImpl(MethodImplOptions.NoInlining)]
    private static WeakReference RunEvaluation()
    {
        Type payload = CreateCollectibleType();
        var weak = new WeakReference(payload);
        Pause();
        GC.KeepAlive(payload);
        return weak;
    }

    public static int Main()
    {
        WeakReference collectibleType = RunEvaluation();

        Log("the debugger resumed the process; nothing in the debuggee references the collectible assembly any more");
        for (int i = 0; i < 10; i++)
        {
            Log($"before GC #{i}: collectible type alive = {collectibleType.IsAlive}");
            GC.Collect();
            GC.WaitForPendingFinalizers();
        }

        Log($"SURVIVED: all collections completed; collectible type alive = {collectibleType.IsAlive}");
        return 0;
    }
}
Driver\Driver.csproj
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <PlatformTarget>x64</PlatformTarget>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="ClrDebug" Version="0.4.2" />
    <PackageReference Include="Microsoft.Diagnostics.DbgShim.win-x64" Version="10.0.745401" />
  </ItemGroup>
</Project>
Driver\Program.cs
using System.Diagnostics;
using System.Runtime.InteropServices;
using ClrDebug;

// Minimal ICorDebug client for the repro:
//   1. launches <host> <Debuggee.dll> under the debugger (dbgshim),
//   2. remembers the ICorDebugClass of CollectiblePayload (LoadClass callback),
//   3. at Debugger.Break() func-evals Producer.Create<CollectiblePayload>() (returns a ref struct, so the
//      runtime keeps the result in external memory that it registers with the GC),
//   4. KEEPS the returned ICorDebugValue alive, so the debuggee-side external memory handle stays registered,
//   5. continues; the debuggee unloads the collectible assembly and forces more garbage collections.
internal static class Program
{
    private static readonly Stopwatch s_clock = Stopwatch.StartNew();
    private static readonly List<object> s_keepAlive = new();
    private static readonly ManualResetEventSlim s_processExited = new(false);
    private static readonly object s_logGate = new();

    private static PSTARTUP_CALLBACK? s_startupCallback;
    private static Process? s_debuggee;
    private static CorDebugModule? s_debuggeeModule;
    private static CorDebugClass? s_payloadClass;
    private static bool s_evalStarted;
    private static int s_unloadNotifications;
    private static string? s_failure;

    [DllImport("kernel32.dll")]
    private static extern uint SetErrorMode(uint mode);

    private static void Log(string message)
    {
        lock (s_logGate)
        {
            Console.WriteLine($"[driver {s_clock.Elapsed.TotalSeconds,7:F2}s] {message}");
        }
    }

    private static string Safe(Func<object?> getter)
    {
        try
        {
            return getter()?.ToString() ?? "<null>";
        }
        catch (Exception ex)
        {
            return $"<{ex.GetType().Name}>";
        }
    }

    private static int Main(string[] args)
    {
        if (args.Length < 2)
        {
            Console.Error.WriteLine("usage: Driver <host-exe (corerun.exe or dotnet.exe)> <path-to-Debuggee.dll> [timeout-seconds]");
            return 2;
        }

        string hostExe = Path.GetFullPath(args[0]);
        string debuggeeDll = Path.GetFullPath(args[1]);
        int timeoutSeconds = args.Length > 2 ? int.Parse(args[2]) : 180;
        string dbgshimPath = Path.Combine(AppContext.BaseDirectory, "runtimes", "win-x64", "native", "dbgshim.dll");

        if (string.Equals(Path.GetFileName(hostExe), "corerun.exe", StringComparison.OrdinalIgnoreCase))
        {
            Environment.SetEnvironmentVariable("CORE_ROOT", Path.GetDirectoryName(hostExe));
        }

        // The debuggee inherits the error mode: a crash must not block on a Windows Error Reporting dialog.
        SetErrorMode(0x0001 | 0x0002);

        var dbgshim = new DbgShim(NativeLibrary.Load(dbgshimPath));
        string commandLine = $"\"{hostExe}\" \"{debuggeeDll}\"";
        Log($"launching: {commandLine}");
        CreateProcessForLaunchResult launch = dbgshim.CreateProcessForLaunch(
            commandLine, true, IntPtr.Zero, Path.GetDirectoryName(debuggeeDll));
        s_debuggee = Process.GetProcessById(launch.ProcessId);
        _ = s_debuggee.Handle; // open a handle now so the exit code remains available

        var callback = new CorDebugManagedCallback();
        callback.OnAnyEvent += (_, e) =>
        {
            // ClrDebug never resumes the process by itself: every callback must end with Continue.
            if (e.Kind != CorDebugManagedCallbackKind.ExitProcess && e.Continue)
            {
                try
                {
                    e.Controller.Continue(false);
                }
                catch (Exception ex)
                {
                    Log($"Continue failed after {e.Kind}: {ex.Message}");
                }
            }
        };
        callback.OnLoadModule += (_, e) =>
        {
            string name = Safe(() => e.Module.Name);
            if (name.EndsWith("Debuggee.dll", StringComparison.OrdinalIgnoreCase))
            {
                s_debuggeeModule = e.Module;
                Log($"callback: LoadModule {name}");
            }
            else if (name == "CollectibleAssembly")
            {
                Log($"callback: LoadModule {name} (dynamic, collectible)");
            }
        };
        callback.OnLoadClass += (_, e) =>
        {
            if (Safe(() => e.C.Module.Name) == "CollectibleAssembly")
            {
                s_payloadClass = e.C;
                Log("callback: LoadClass CollectiblePayload");
            }
        };
        callback.OnBreak += OnBreak;
        callback.OnEvalComplete += OnEvalComplete;
        callback.OnEvalException += OnEvalException;
        callback.OnUnloadModule += (_, e) =>
        {
            s_unloadNotifications++;
            Log($"callback: UnloadModule {Safe(() => e.Module.Name)}   <-- the collectible assembly was unloaded");
        };
        callback.OnUnloadClass += (_, _) => Log("callback: UnloadClass");
        callback.OnExitProcess += (_, _) =>
        {
            Log("callback: ExitProcess");
            s_processExited.Set();
        };

        s_startupCallback = (pCordb, _, hr) =>
        {
            try
            {
                if (hr != HRESULT.S_OK)
                {
                    s_failure = $"runtime startup callback reported {hr}";
                    s_processExited.Set();
                    return;
                }

                var corDebug = new CorDebug((ICorDebug)Marshal.GetObjectForIUnknown(pCordb));
                corDebug.Initialize();
                corDebug.SetManagedHandler(callback);
                CorDebugProcess process = corDebug.DebugActiveProcess(launch.ProcessId, false);
                s_keepAlive.Add(corDebug);
                s_keepAlive.Add(process);
                Log("attached to the runtime");
            }
            catch (Exception ex)
            {
                s_failure = $"attach failed: {ex}";
                s_processExited.Set();
            }
        };

        IntPtr unregisterToken = dbgshim.RegisterForRuntimeStartup(launch.ProcessId, s_startupCallback, IntPtr.Zero);
        dbgshim.ResumeProcess(launch.ResumeHandle);
        dbgshim.CloseResumeHandle(launch.ResumeHandle);

        if (!s_processExited.Wait(TimeSpan.FromSeconds(timeoutSeconds)))
        {
            Log($"TIMEOUT after {timeoutSeconds}s; killing the debuggee");
            s_debuggee.Kill(true);
        }

        s_debuggee.WaitForExit(30_000);
        dbgshim.UnregisterForRuntimeStartup(unregisterToken);

        uint exitCode = unchecked((uint)s_debuggee.ExitCode);
        Log($"unload notifications seen: {s_unloadNotifications}");
        Log($"debuggee exit code: 0x{exitCode:X8}{(exitCode == 0xC0000005 ? " (STATUS_ACCESS_VIOLATION)" : string.Empty)}");
        if (s_failure is not null)
        {
            Log($"FAILURE: {s_failure}");
            return 3;
        }

        return exitCode == 0 ? 0 : 1;
    }

    private static void Fail(string what, Exception ex)
    {
        s_failure = $"{what}: {ex}";
        Log($"FAILURE: {s_failure}");
        try
        {
            s_debuggee?.Kill(true);
        }
        catch
        {
        }
    }

    private static void OnBreak(object? sender, BreakCorDebugManagedCallbackEventArgs e)
    {
        if (s_evalStarted)
        {
            return;
        }

        s_evalStarted = true;
        if (Environment.GetEnvironmentVariable("REPRO_SKIP_EVAL") == "1")
        {
            Log("break: REPRO_SKIP_EVAL=1, not evaluating anything (control run)");
            return;
        }

        try
        {
            CorDebugModule module = s_debuggeeModule ?? throw new InvalidOperationException("Debuggee.dll was not seen");
            CorDebugClass payloadClass = s_payloadClass ?? throw new InvalidOperationException("CollectiblePayload was not seen");

            // Resolve Producer.Create through metadata.
            MetaDataImport import = module.GetMetaDataInterface<MetaDataImport>();
            mdTypeDef producer = import.FindTypeDefByName("Producer", mdToken.Nil);
            mdMethodDef method = import.FindMethod(producer, "Create", IntPtr.Zero, 0);
            CorDebugFunction function = module.GetFunctionFromToken(method);

            // ICorDebugType for the collectible class: the type argument of the generic method.
            CorDebugType payloadType = payloadClass.GetParameterizedType(CorElementType.Class, 0, Array.Empty<ICorDebugType>());

            CorDebugEval eval = e.Thread.CreateEval();
            eval.CallParameterizedFunction(function.Raw, 1, new[] { payloadType.Raw }, 0, Array.Empty<ICorDebugValue>());
            s_keepAlive.Add(eval);
            Log("break: started the func-eval of Producer.Create<CollectiblePayload>()");
        }
        catch (Exception ex)
        {
            Fail("break handler", ex);
        }
    }

    private static void OnEvalComplete(object? sender, EvalCompleteCorDebugManagedCallbackEventArgs e)
    {
        try
        {
            CorDebugValue result = e.Eval.Result;
            Log($"func-eval complete: result element type {result.Type}, size {result.Size}, " +
                $"address {Safe(() => result.Address)}, wrapper {result.GetType().Name}");

            if (Environment.GetEnvironmentVariable("REPRO_RELEASE_RESULT") == "1")
            {
                // Control: do not retain anything, so the right side can dispose the external memory owner.
                Marshal.FinalReleaseComObject(result.Raw);
                Marshal.FinalReleaseComObject(e.Eval.Raw);
                Log("control: released the result value and the eval; the debugger no longer owns the external memory");
                return;
            }

            // Retain the value (and the eval): the debugger still owns the debuggee-side external memory.
            s_keepAlive.Add(e.Eval);
            s_keepAlive.Add(result);
            Log("the debugger retains the result value and resumes the debuggee");
        }
        catch (Exception ex)
        {
            Fail("GetResult", ex);
        }
    }

    private static void OnEvalException(object? sender, EvalExceptionCorDebugManagedCallbackEventArgs e)
    {
        Log("callback: EvalException (the func-eval threw)");
        s_failure = "the func-eval threw";
    }
}

Expected behavior

The external-memory registration keeps alive everything it dereferences. The collectible LoaderAllocator that owns the registered type stays loaded for as long as the owner exists, and can be unloaded again once the debugger disposes of the value. The debuggee does not crash.

Actual behavior

The collectible assembly is unloaded while the debugger still holds the func-eval result, and a later collection crashes:

[debuggee] before GC #0: collectible type alive = True
[driver]   callback: UnloadClass
[driver]   callback: UnloadModule CollectibleAssembly   <-- the collectible assembly was unloaded
[debuggee] before GC #1: collectible type alive = False
[debuggee] before GC #2: collectible type alive = False
Fatal error.
Internal CLR error. (0x80131506)
   at System.GC.Collect()
   at Program.Main()

It failed in every run (10 of 10): GC #1, which already runs after the UnloadModule notification, passes, and GC #2 faults. The process then hangs instead of exiting: the faulting thread sits in the last-chance debugger notification, waiting for the GC that it is itself running. A dump taken at that point (Release build of f5f66b5f, symbolized) shows the faulting frames:

ntdll!KiUserExceptionDispatcher
coreclr!MethodTable::IsValueType                   methodtable.inl:299
coreclr!ExternalMemoryHandle::GCScanRoot           externalmemoryhandle.cpp:101
coreclr!ExternalMemoryHandle::GCScanRoots          externalmemoryhandle.cpp:87
coreclr!GCToEEInterface::GcScanRoots               gcenv.ee.cpp:350
coreclr!GCScan::GcScanRoots                        gcscan.cpp:154
coreclr!WKS::gc_heap::mark_phase                   mark_phase.cpp:3034
coreclr!WKS::gc_heap::gc1                          collect.cpp:170
coreclr!WKS::gc_heap::garbage_collect              collect.cpp:1308
coreclr!WKS::GCHeap::GarbageCollectGeneration      interface.cpp:1967
coreclr!WKS::GCHeap::GarbageCollect                interface.cpp:1786
coreclr!GCInterface_Collect                        comutilnative.cpp:766
System.Private.CoreLib!System.GC.Collect
Program.Main

(Above KiUserExceptionDispatcher the same thread is in CLRVectoredExceptionHandler -> EEPolicy::HandleFatalError -> NotifyDebuggerLastChance -> Debugger::SendExceptionHelperAndBlock -> Thread::RareDisablePreemptiveGC -> GCHeap::WaitUntilGCComplete.)

The variations isolate the cause (all on the same Release build of f5f66b5f, unless noted):

Variation Result
default: debugger retains the result assembly unloads, fatal error in GC #2 (10/10 runs)
REPRO_SKIP_EVAL=1: no func-eval assembly unloads under the debugger, all collections pass
REPRO_RELEASE_RESULT=1: result and eval released assembly unloads, all collections pass
default, with the patch from "Other information" all collections pass; the assembly stays loaded (no UnloadModule) while the result is retained (8/8 runs)
REPRO_RELEASE_RESULT=1, with the patch assembly unloads again, all collections pass

Regression?

Not shipped. DebuggerExternalMemoryOwner and ExternalMemoryHandle were added by #133986 (merged 2026-09-29), so only builds that contain that change are affected.

Known Workarounds

For a debugger: release the result ICorDebugValue (and the ICorDebugEval) of ref struct func-evals before resuming the process (the REPRO_RELEASE_RESULT=1 control).

Configuration

  • dotnet/runtime main at f5f66b5f3ceadcd7daca3098b48ba5c8930f01f7, Release CoreCLR and Release libraries, x64.
  • Windows (build 26200) x64. Driver: ClrDebug 0.4.2 and Microsoft.Diagnostics.DbgShim.win-x64 10.0.745401.
  • Not tried on other operating systems or architectures; nothing in the affected code is Windows-specific.

Other information

Possible fix. Keep the collectible loader allocator alive for as long as the owner exists. A minimal version, which I verified with the repro above (it is applied in DebuggerExternalMemoryOwner; GetExposedObject() is null for a non-collectible allocator, so no extra handle is created in that case):

Patch
diff --git a/src/coreclr/debug/ee/debugger.cpp b/src/coreclr/debug/ee/debugger.cpp
--- a/src/coreclr/debug/ee/debugger.cpp
+++ b/src/coreclr/debug/ee/debugger.cpp
@@ -1317,9 +1317,18 @@ DebuggerEval::DebuggerEval(CONTEXT * pContext, DebuggerIPCE_FuncEvalInfo * pEval
     }
 }
 
+static void ReleaseDebuggerExternalMemoryHandle(ExternalMemoryHandle* pHandle)
+{
+    WRAPPER_NO_CONTRACT;
+
+    ExternalMemoryHandle::Remove(pHandle DEBUG_ARG(g_pDebugger->IsStopped()));
+}
+
 DebuggerExternalMemoryOwner::DebuggerExternalMemoryOwner(MethodTable *pMT, BYTE *pMemory)
     : m_pHandle(NULL),
-      m_pMemory(pMemory)
+      m_pMemory(pMemory),
+      m_loaderAllocator(NULL),
+      m_loaderAllocatorHandle(NULL)
 {
     CONTRACTL
     {
@@ -1329,14 +1338,28 @@ DebuggerExternalMemoryOwner::DebuggerExternalMemoryOwner(MethodTable *pMT, BYTE
     }
     CONTRACTL_END;
 
+    // The registration of the value reports the references inside of it, but it does not keep the value's type alive.
+    // When that type belongs to a collectible LoaderAllocator, root the allocator for as long as the registration
+    // exists. Otherwise the allocator can be unloaded and the registration would walk a freed MethodTable.
+    m_loaderAllocator = pMT->GetLoaderAllocator()->GetExposedObject();
+    Holder<ExternalMemoryHandle*, DoNothing<ExternalMemoryHandle*>, ReleaseDebuggerExternalMemoryHandle>
+        keepAlive(m_loaderAllocator != NULL
+            ? ExternalMemoryHandle::Add(g_pObjectClass, &m_loaderAllocator, 0)
+            : NULL);
     m_pHandle = ExternalMemoryHandle::Add(pMT, m_pMemory, 0);
+    m_loaderAllocatorHandle = keepAlive.GetValue();
+    keepAlive.SuppressRelease();
 }
 
 DebuggerExternalMemoryOwner::~DebuggerExternalMemoryOwner()
 {
     WRAPPER_NO_CONTRACT;
 
-    ExternalMemoryHandle::Remove(m_pHandle DEBUG_ARG(g_pDebugger->IsStopped()));
+    ReleaseDebuggerExternalMemoryHandle(m_pHandle);
+    if (m_loaderAllocatorHandle != NULL)
+    {
+        ReleaseDebuggerExternalMemoryHandle(m_loaderAllocatorHandle);
+    }
     DeleteInteropSafe(m_pMemory);
 }
 
diff --git a/src/coreclr/debug/ee/debugger.h b/src/coreclr/debug/ee/debugger.h
--- a/src/coreclr/debug/ee/debugger.h
+++ b/src/coreclr/debug/ee/debugger.h
@@ -3381,6 +3381,8 @@ public:
 private:
     ExternalMemoryHandle *m_pHandle;
     BYTE                 *m_pMemory;
+    OBJECTREF             m_loaderAllocator;
+    ExternalMemoryHandle *m_loaderAllocatorHandle;
 };

Alternatively the keep-alive could live in ExternalMemoryHandle itself, so that any future caller that registers a MethodTable from a collectible allocator is covered as well; DebuggerExternalMemoryOwner is currently the only caller.

Checked builds. This repro cannot be used with a Checked CoreCLR. Unloading any collectible assembly while a managed debugger is attached already fails there, even with no func-eval at all (REPRO_SKIP_EVAL=1): Debugger::UnloadModule -> Debugger::TrapAllRuntimeThreads reports a contract violation (GC_TRIGGERS encountered in a GC_NOTRIGGER scope), and the process then faults inside the contract machinery (CONTRACT_ASSERT -> sprintf_s -> strnlen). That is a separate problem from this one, which is why the repro uses a Release build.

Related. #135245, #135247 and #135251 describe other, different problems with debugger func-eval and external memory results. The driver above (retain the ICorDebugValue of a func-eval result, then resume) might also be useful as a small public harness for those.

Note

This issue was drafted with GitHub Copilot.

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.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions