Repository navigation
Func-eval ref struct result: ExternalMemoryHandle doesn't keep a collectible LoaderAllocator alive (access violation in GC after unload) #135252
Description
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:
Debugger::FuncEvalCompletehands the owner to the right side and clearspDE->m_externalMemoryOwner(debugger.cpp#L9885-L9912).CordbEval::GetResultmoves it to the returnedCordbVCObjectValue(rsthread.cpp#L10485-L10513), which sendsDB_IPCE_DISPOSE_EXTERNAL_MEMORY_OWNERonly when it is neutered (divalue.cpp#L2979-L2996). These values are on the exit neuter list and are not neutered on Continue (divalue.cpp#L2925-L2931).
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).
-
Build CoreCLR and the libraries from
mainin 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"
-
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
-
Run the driver. It launches the debuggee under the debugger using
corerun.exefrom 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:
- The debuggee creates a
RunAndCollectassembly with an empty classCollectiblePayload, then callsDebugger.Break().Producer.Create<T>()returns aView<T>(aref structwith aref intfield), and the closed typeView<CollectiblePayload>lives in the collectibleLoaderAllocator. - The debugger (driver) takes the
ICorDebugClassofCollectiblePayloadfrom theLoadClasscallback and func-evalsProducer.Create<CollectiblePayload>(). It callsGetResultand keeps the returnedICorDebugValueandICorDebugEvalalive, then continues. - 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
mainat f5f66b5f3ceadcd7daca3098b48ba5c8930f01f7, Release CoreCLR and Release libraries, x64. - Windows (build 26200) x64. Driver:
ClrDebug0.4.2 andMicrosoft.Diagnostics.DbgShim.win-x6410.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
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.