Skip to content

Remove ExInfo::m_hThrowable — use direct pointer for exception objects#127300

Draft
max-charlamb wants to merge 1 commit intomainfrom
dev/max-charlamb/exception-direct-pointer
Draft

Remove ExInfo::m_hThrowable — use direct pointer for exception objects#127300
max-charlamb wants to merge 1 commit intomainfrom
dev/max-charlamb/exception-direct-pointer

Conversation

@max-charlamb
Copy link
Copy Markdown
Member

@max-charlamb max-charlamb commented Apr 22, 2026

Note

This PR was authored with the assistance of GitHub Copilot.

Summary

Replace the GCHandle-based m_hThrowable field in ExInfo with direct use of the existing m_exception OBJECTREF field, matching NativeAOT's approach.

Motivation

CoreCLR's ExInfo stored exception objects via two redundant fields: OBJECTHANDLE m_hThrowable (GC handle table indirection) and OBJECTREF m_exception (direct pointer used by the new EH path shared with NativeAOT). NativeAOT only has m_exception. The handle added allocation/deallocation overhead (~5 interlocked ops per throw) and an extra pointer indirection on every read, but none of the 15 consumers actually required OBJECTHANDLE guarantees -- they all ran in cooperative GC mode and immediately dereferenced the handle.

Key Changes

  • Remove OBJECTHANDLE m_hThrowable from ExInfo, saving 8 bytes (64-bit)
  • Update AsmOffsets constants for the new field layout (validated by static_asserts)
  • Add GC root scanning of ExInfo chain in ScanStackRoots (gcenv.ee.cpp), mirroring NativeAOT's GcScanRootsWorker -- this keeps superseded exception objects alive without handles
  • Remove GetThrowableAsHandle() entirely -- all callers migrated to use GetThrowable() (OBJECTREF) or m_LastThrownObjectHandle (real handle on Thread)
  • Fix StackTraceInfo::AppendElement OBJECTREF overload to preserve foreign-exception semantics and preallocated-exception checks
  • Update Interop propagation callback to take OBJECTREF instead of OBJECTHANDLE
  • Update DAC code: ClrDataExceptionState::m_throwable uses m_LastThrownObjectHandle (real handle) for current exception, and target address of m_exception for nested exception iteration
  • Update cDAC: ThrownObjectHandle -> ThrownObject (direct pointer, no handle dereference)

What stays unchanged

Thread::m_LastThrownObjectHandle remains as an OBJECTHANDLE -- it is required by the ICorDebug managed debugging protocol (SendExceptionHelperAndBlock is MODE_ANY, right-side debugger reads through handle cross-process via BuildFromGCHandle).

Testing

  • CLR build: 0 errors, 0 warnings
  • All 1731 cDAC tests pass
  • AsmOffset static_asserts validate all field offsets

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR removes ExInfo::m_hThrowable (GC-handle indirection) and standardizes on the existing direct OBJECTREF m_exception path, aligning CoreCLR with NativeAOT and updating DAC/cDAC consumers accordingly.

Changes:

  • Remove m_hThrowable usage and migrate exception-object reads to m_exception across EH, interop propagation, debugger/DAC paths.
  • Add explicit GC root scanning for the ExInfo chain to keep superseded exception objects alive without handles.
  • Update cDAC contracts/tests and DAC exception state plumbing to reflect the new representation.

Reviewed changes

Copilot reviewed 25 out of 25 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/native/managed/cdac/tests/ThreadTests.cs Updates tests to use the new thrown-object representation.
src/native/managed/cdac/tests/MockDescriptors/MockDescriptors.Thread.cs Updates mock ExceptionInfo layout to expose ThrownObject instead of ThrownObjectHandle.
src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Data/ExceptionInfo.cs Reads ThrownObject as a pointer field instead of a handle wrapper.
src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/Thread_1.cs Returns current exception “handle” using ThrownObject and updates Watson bucket lookup accordingly.
src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/Exception_1.cs Updates nested exception info to return direct thrown object pointer.
src/coreclr/vm/threads.h Removes handle-based throwable accessors and adjusts HasException/IsThrowableNull logic.
src/coreclr/vm/threads.cpp Updates last-thrown synchronization to use OBJECTREF throwable.
src/coreclr/vm/interoplibinterface_shared.cpp Changes propagating-exception callback signature/GC mode to take OBJECTREF.
src/coreclr/vm/interoplibinterface_objc.cpp Switches ObjC propagation callback to accept OBJECTREF and removes handle dereference.
src/coreclr/vm/interoplibinterface.h Updates declarations to match OBJECTREF callback signatures.
src/coreclr/vm/gcenv.ee.cpp Adds GC root scanning of ExInfo chain for direct exception object references.
src/coreclr/vm/exstate.h Removes GetThrowableAsHandle from ThreadExceptionState.
src/coreclr/vm/exstate.cpp Removes handle-based throwable retrieval; GetThrowable returns m_exception.
src/coreclr/vm/exinfo.h Removes m_hThrowable and returns m_exception directly from GetThrowable().
src/coreclr/vm/exinfo.cpp Drops handle lifecycle management; clears m_exception during resource release.
src/coreclr/vm/exceptionhandling.cpp Updates DAC memory enumeration and stacktrace append paths to use m_exception.
src/coreclr/vm/excep.cpp Updates stacktrace appending to preserve foreign/preallocated semantics with OBJECTREF.
src/coreclr/vm/eedbginterfaceimpl.cpp Switches debugger exception retrieval logic to rely on m_LastThrownObjectHandle.
src/coreclr/vm/datadescriptor/datadescriptor.inc Updates cDAC descriptor field to ThrownObject at offsetof(ExInfo, m_exception).
src/coreclr/debug/ee/debugger.cpp Uses m_LastThrownObjectHandle for force-catch-handler lookup.
src/coreclr/debug/daccess/task.cpp Changes ClrDataExceptionState::m_throwable type to TADDR and passes &m_exception.
src/coreclr/debug/daccess/request.cpp Reads exception object directly from m_exception and updates Watson bucket retrieval.
src/coreclr/debug/daccess/dacimpl.h Updates ClrDataExceptionState signature/storage for TADDR throwable.
src/coreclr/debug/daccess/dacdbiimpl.cpp Switches “current exception” debugger handle to m_LastThrownObjectHandle.
src/coreclr/System.Private.CoreLib/src/System/Runtime/ExceptionServices/AsmOffsets.cs Updates managed EH asm offsets to reflect the new ExInfo layout.

Comment on lines 220 to 231
TargetPointer IThread.GetCurrentExceptionHandle(TargetPointer threadPointer)
{
var (_, exceptionInfo) = GetThreadExceptionInfo(threadPointer);

if (exceptionInfo == null)
return TargetPointer.Null;

if (exceptionInfo.ThrownObjectHandle.Handle == TargetPointer.Null || exceptionInfo.ThrownObjectHandle.Object == TargetPointer.Null)
if (exceptionInfo.ThrownObject == TargetPointer.Null)
return TargetPointer.Null;

return exceptionInfo.ThrownObjectHandle.Handle;
return exceptionInfo.ThrownObject;
}
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetCurrentExceptionHandle now returns the exception object pointer (ExceptionInfo.ThrownObject). Several existing cDAC legacy COM wrappers (e.g., Microsoft.Diagnostics.DataContractReader.Legacy.ClrDataTask.GetCurrentExceptionState) treat this value as an OBJECTHANDLE-like address and dereference it via Target.ReadPointer; returning an object address will cause them to read the method table pointer instead of the exception object. To preserve compatibility, either return a pseudo-handle address (exceptionInfoAddr + field offset of ThrownObject) or update all legacy consumers to treat this value as a direct object pointer (and stop dereferencing).

Copilot uses AI. Check for mistakes.
Comment on lines 17 to +25
TargetPointer IException.GetNestedExceptionInfo(TargetPointer exceptionInfoAddr, out TargetPointer nextNestedExceptionInfo, out TargetPointer thrownObjectHandle)
{
Data.ExceptionInfo exceptionInfo = _target.ProcessedData.GetOrAdd<Data.ExceptionInfo>(exceptionInfoAddr);
nextNestedExceptionInfo = exceptionInfo.PreviousNestedInfo;
thrownObjectHandle = exceptionInfo.ThrownObjectHandle.Handle;
return exceptionInfo.ThrownObjectHandle.Object;
// ThrownObject is now a direct object pointer, not a handle.
// For backward compatibility, thrownObjectHandle out-param returns TargetPointer.Null
// since there is no handle. The return value is the object pointer itself.
thrownObjectHandle = TargetPointer.Null;
return exceptionInfo.ThrownObject;
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetNestedExceptionInfo sets thrownObjectHandle to TargetPointer.Null for “backward compatibility”, but existing consumers of the out-param (e.g., the cDAC Legacy ClrDataExceptionState implementation) expect a dereferenceable handle address to fetch the exception object. Consider returning a pseudo-handle (exceptionInfoAddr + offset of the ThrownObject field) in thrownObjectHandle and returning the object pointer as the return value, so legacy code can continue to dereference the handle while newer code can use the direct return value.

Copilot uses AI. Check for mistakes.
Comment thread src/coreclr/vm/exceptionhandling.cpp Outdated
Comment on lines +2927 to +2928
TADDR objAddr = dac_cast<TADDR>(m_exception);
DacEnumMemoryRegion(objAddr, sizeof(Object));
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In DAC builds this change enumerates only sizeof(Object) at the exception address. Previously OBJECTHANDLE_EnumMemoryRegions would also call obj->EnumMemoryRegions(), ensuring the full exception object graph is present in triage/mini dumps for SOS/debugging. Consider switching to OBJECTREF_EnumMemoryRegions(m_exception) (or equivalent) so the entire exception object is enumerated rather than just its header.

Suggested change
TADDR objAddr = dac_cast<TADDR>(m_exception);
DacEnumMemoryRegion(objAddr, sizeof(Object));
OBJECTREF_EnumMemoryRegions(m_exception);

Copilot uses AI. Check for mistakes.
Comment thread src/coreclr/vm/threads.h Outdated
{
WRAPPER_NO_CONTRACT;
return IsHandleNullUnchecked(m_ExceptionState.GetThrowableAsHandle());
return !m_ExceptionState.IsExceptionInProgress();
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IsThrowableNull now returns !m_ExceptionState.IsExceptionInProgress(), which only checks whether a tracker exists, not whether the current tracker actually has a non-null throwable. This changes semantics for callers that used IsThrowableNull/HasException as a proxy for “is there a current exception object” (previously it checked handle/object nullness) and can return false even when the current tracker’s throwable has been cleared. Consider implementing an unchecked null test against the current tracker’s m_exception field (similar to the previous handle-based check) so IsThrowableNull reflects whether a throwable is present.

Suggested change
return !m_ExceptionState.IsExceptionInProgress();
return m_ExceptionState.GetThrowable() == NULL;

Copilot uses AI. Check for mistakes.
@max-charlamb max-charlamb force-pushed the dev/max-charlamb/exception-direct-pointer branch from db309be to e6688b3 Compare April 22, 2026 21:54
Copilot AI review requested due to automatic review settings April 22, 2026 22:12
@max-charlamb max-charlamb force-pushed the dev/max-charlamb/exception-direct-pointer branch from e6688b3 to 2812642 Compare April 22, 2026 22:12
@max-charlamb max-charlamb force-pushed the dev/max-charlamb/exception-direct-pointer branch from 2812642 to 9d54eec Compare April 22, 2026 22:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 3 comments.

Comment on lines 262 to 266
{
thread = threadBuilder.AddThread(1, 1234);
exceptionInfo = threadBuilder.GetExceptionInfo(thread);
TargetTestHelpers helpers = threadBuilder.Builder.TargetTestHelpers;
MockMemorySpace.BumpAllocator allocator = threadBuilder.Builder.CreateAllocator(0x1_0000, 0x2_0000);
MockMemorySpace.HeapFragment handleFragment = allocator.Allocate((ulong)helpers.PointerSize, "ThrownObjectHandle");
helpers.WritePointer(handleFragment.Data, TargetPointer.Null);
exceptionInfo!.ThrownObjectHandle = handleFragment.Address;
exceptionInfo!.ThrownObject = 0;
});
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test case now sets exceptionInfo.ThrownObject = 0 to represent a null exception. If GetCurrentExceptionHandle continues to return a handle/slot address, the null scenario should be represented by a non-null slot whose contents are null (or by LastThrownObjectHandle being null), matching how consumers dereference handles.

Copilot uses AI. Check for mistakes.
Comment on lines +21 to +25
// ThrownObject is a direct object pointer stored in ExInfo::m_exception.
// Return the address of the field as a "handle" — reading through it yields the
// exception Object*. This has the same lifetime as the ExInfo (both are invalidated
// when PopExInfos calls ReleaseResources). See dacimpl.h for the equivalent native
// DAC documentation.
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IException.GetNestedExceptionInfo sets thrownObjectHandle to TargetPointer.Null, but downstream legacy shims (e.g., ClrDataExceptionState) treat this out-param as an address to dereference to obtain the exception object. Returning null here will break nested exception iteration (GetPrevious/GetString/etc.). Please keep providing a valid "handle-slot" address for backward compatibility, e.g., the target address of the ExceptionInfo.ThrownObject field (a slot containing an Object*), even though the return value is now the direct object pointer.

Copilot uses AI. Check for mistakes.
Comment on lines +223 to +230
thread = threadBuilder.AddThread(1, 1234);
exceptionInfo = threadBuilder.GetExceptionInfo(thread);
TargetTestHelpers helpers = threadBuilder.Builder.TargetTestHelpers;
MockMemorySpace.BumpAllocator allocator = threadBuilder.Builder.CreateAllocator(0x1_0000, 0x2_0000);
MockMemorySpace.HeapFragment handleFragment = allocator.Allocate((ulong)helpers.PointerSize, "ThrownObjectHandle");
helpers.WritePointer(handleFragment.Data, expectedObject);
exceptionInfo!.ThrownObjectHandle = handleFragment.Address;
exceptionInfo!.ThrownObject = (ulong)expectedObject;
});

IThread contract = target.Contracts.Thread;
TargetPointer thrownObjectHandle = contract.GetCurrentExceptionHandle(new TargetPointer(thread!.Address));
Assert.Equal(new TargetPointer(exceptionInfo!.ThrownObjectHandle), thrownObjectHandle);
Assert.Equal(new TargetPointer(exceptionInfo!.ThrownObject), thrownObjectHandle);
Copy link

Copilot AI Apr 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test now sets exceptionInfo.ThrownObject to the exception object address and asserts GetCurrentExceptionHandle returns that value. However, GetCurrentExceptionHandle is expected to return a handle-slot address (something callers can dereference to obtain the exception object), not the object address itself. If the contract continues to return a handle/slot, the test should allocate a slot, write expectedObject into it, and assert the returned pointer equals the slot address.

Copilot uses AI. Check for mistakes.
@max-charlamb max-charlamb force-pushed the dev/max-charlamb/exception-direct-pointer branch from 9d54eec to e761d3e Compare April 23, 2026 02:35
Copilot AI review requested due to automatic review settings April 23, 2026 03:01
@max-charlamb max-charlamb force-pushed the dev/max-charlamb/exception-direct-pointer branch from e761d3e to 34f7963 Compare April 23, 2026 03:01
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 1 comment.

Comment on lines +95 to +98
// The exception object is stored directly in ExInfo::m_exception by managed EH code.
// SetThrowable is now a no-op for the ExInfo field — the m_exception is already set.
// We keep this method so that SafeSetThrowables can still call it for the assertion
// and debug checks above.
Copy link

Copilot AI Apr 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ThreadExceptionState::SetThrowable is now effectively a no-op and never updates/clears the current ExInfo’s m_exception. This breaks callers that rely on SetThrowable to actually change the current throwable (e.g., Thread::SafeSetThrowables catch path that intends to replace both current throwable and LTO with the preallocated OOM exception on handle-allocation failure) and also means SafeSetThrowables(NULL) no longer clears the current tracker’s throwable, potentially keeping a stale exception OBJECTREF rooted longer than intended. Consider setting m_pCurrentTracker->m_exception = throwable when m_pCurrentTracker != NULL (and clearing it when throwable == NULL), while still allowing the fatal-SO “current tracker may be null” case.

Suggested change
// The exception object is stored directly in ExInfo::m_exception by managed EH code.
// SetThrowable is now a no-op for the ExInfo field — the m_exception is already set.
// We keep this method so that SafeSetThrowables can still call it for the assertion
// and debug checks above.
if (m_pCurrentTracker != NULL)
{
m_pCurrentTracker->m_exception = throwable;
}

Copilot uses AI. Check for mistakes.
Replace the GCHandle-based m_hThrowable field in ExInfo with direct use
of the existing m_exception OBJECTREF field, matching NativeAOT's approach.

Key changes:
- Remove OBJECTHANDLE m_hThrowable from ExInfo, saving 8 bytes (64-bit)
- Update AsmOffsets constants for the new field layout
- Add GC root scanning of ExInfo chain in ScanStackRoots (gcenv.ee.cpp),
  mirroring NativeAOT's GcScanRootsWorker pattern
- Simplify GetThrowable() to return m_exception directly
- SetThrowable() no longer creates GC handles for ExInfo
- Remove GetThrowableAsHandle() entirely — all callers migrated to use
  GetThrowable() (OBJECTREF) or m_LastThrownObjectHandle (real handle)
- Update StackTraceInfo::AppendElement OBJECTREF overload to preserve
  foreign-exception semantics and preallocated exception checks
- Update Interop propagation callback to take OBJECTREF instead of handle
- Update DAC code (request.cpp, task.cpp, dacdbiimpl.cpp) to use
  m_exception directly
- Update debugger code (eedbginterfaceimpl.cpp, debugger.cpp) to use
  m_LastThrownObjectHandle for handle-based APIs
- Update cDAC: ThrownObjectHandle -> ThrownObject (direct pointer)
- Update cDAC contracts, data classes, and tests

This eliminates ~5 interlocked handle alloc/destroy ops per exception
throw, removes OOM fallback paths, and unblocks cDAC unification.
Thread::m_LastThrownObjectHandle remains as-is (separate work item).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings April 24, 2026 01:38
@max-charlamb max-charlamb force-pushed the dev/max-charlamb/exception-direct-pointer branch from adffa37 to 7aae554 Compare April 24, 2026 01:38
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 25 out of 25 changed files in this pull request and generated 2 comments.

Comment on lines +214 to +219
PTR_ExInfo pExInfo = pThread->GetExceptionState()->GetCurrentExceptionTracker();
while (pExInfo != NULL)
{
PTR_PTR_Object pRef = dac_cast<PTR_PTR_Object>(&pExInfo->m_exception);
fn(pRef, sc, 0);
pExInfo = pExInfo->GetPreviousExceptionTracker();
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScanStackRoots reports ExInfo::m_exception as a GC root using fn(..., sc, 0). Most other explicit object-root reports in this file use CHECK_APP_DOMAIN, and omitting it can change GC root reporting semantics (especially for domain/loader allocator checks). Consider passing CHECK_APP_DOMAIN (or the appropriate flag set) when promoting the m_exception slot.

Copilot uses AI. Check for mistakes.
#endif // _DEBUG

StackTraceInfo::AppendElement(pExInfo->m_hThrowable, ip, sp, pMD, &pExInfo->m_frameIter.m_crawl);
StackTraceInfo::AppendElement((OBJECTHANDLE)&pExInfo->m_exception, ip, sp, pMD, &pExInfo->m_frameIter.m_crawl);
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These calls pass the address of ExInfo::m_exception cast to OBJECTHANDLE. Since this is not a real GC handle-table handle (it's an OBJECTREF slot on the stack), it would be easy for future readers to misinterpret and accidentally treat it like a handle-table entry. Consider adding a brief comment or using a named helper/cast to make the "OBJECTREF slot as pseudo-handle" intent explicit at the call site.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants