Skip to content

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

Open
max-charlamb wants to merge 3 commits intomainfrom
dev/max-charlamb/exception-direct-pointer
Open

Remove ExInfo::m_hThrowable — use direct pointer for exception objects#127300
max-charlamb wants to merge 3 commits 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 (x64) / 4 bytes (x86)
  • 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 GCPROTECT_BEGIN(exInfo.m_exception) from all 3 dispatch entry points — the chain scanning already reports &m_exception to the GC, and reporting the same location twice corrupts the GC's relocation logic (clr-code-guide.md §2.1.5). Debug OBJECTREF tracking is satisfied via Thread::ObjectRefProtected in the ExInfo constructor.
  • Remove GetThrowableAsHandle() entirely — all callers migrated to use GetThrowable() (OBJECTREF) or m_LastThrownObjectHandle (real handle on Thread)
  • Remove SetThrowable() entirely — managed EH code writes m_exception directly; SafeSetThrowables now only updates m_LastThrownObjectHandle via SetLastThrownObject. The SetThrowableErrorChecking enum and STEC_* constants are also removed.
  • Fix StackTraceInfo::AppendElement preallocated-exception check: changed from IsPreallocatedExceptionHandle (compares handle pointer against globals — always false for pseudo-handles) to IsPreallocatedExceptionObject(ObjectFromHandle(...)) (compares actual object identity)
  • Update AsmOffsets constants for the new field layout (validated by static_asserts)
  • Update Interop propagation callback to take OBJECTREF instead of OBJECTHANDLE
  • Update DAC code: debugger APIs use m_LastThrownObjectHandle (real handle) for current exception, and target address of m_exception field for nested exception iteration
  • Update cDAC: ThrownObjectHandleThrownObject (direct pointer, no handle dereference); GetCurrentExceptionHandle returns field address as pseudo-handle for backward compatibility

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

Efficiency

Per exception throw, this eliminates:

  • ~5 interlocked operations (handle alloc/destroy)
  • 1 handle table slot allocation + bookkeeping
  • 1 pointer indirection per throwable read (2-hop → 1-hop)
  • 8 bytes from ExInfo struct (x64)
  • 3 GCFrame constructions per dispatch entry point

The only added cost is ~2 pointer reads per thread per GC for ExInfo chain walking — negligible since exception chains are almost always 1–2 nodes deep.

Testing

  • Checked + Release CLR builds: 0 errors, 0 warnings
  • GCStress=0xC + HeapVerify=1: 100/100 nested exception iterations pass (20 levels, 100 iterations)
  • 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 thread src/coreclr/vm/exceptionhandling.cpp Outdated
Comment thread src/coreclr/vm/threads.h Outdated
@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 thread src/native/managed/cdac/tests/ThreadTests.cs
Comment thread src/native/managed/cdac/tests/ThreadTests.cs Outdated
@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 thread src/coreclr/vm/exstate.cpp Outdated
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 thread src/coreclr/vm/gcenv.ee.cpp
Comment thread src/coreclr/vm/exceptionhandling.cpp
The ExInfo::m_exception field was being reported to the GC twice:
once via GCPROTECT_BEGIN and once via ExInfo chain scanning in
ScanStackRoots. The CLR code guide (section 2.1.5) explicitly states
that reporting the same location twice corrupts the GC's relocation
logic. Remove the GCPROTECT_BEGIN/END for m_exception and rely solely
on chain scanning (matching NativeAOT's model). Add
Thread::ObjectRefProtected calls in checked builds to satisfy the
debug OBJECTREF tracking table.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@max-charlamb max-charlamb requested a review from jkotas April 24, 2026 18:58
@max-charlamb max-charlamb marked this pull request as ready for review April 24, 2026 18:58
Copilot AI review requested due to automatic review settings April 24, 2026 18:58
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 27 out of 27 changed files in this pull request and generated 1 comment.

Comment thread src/coreclr/vm/threads.cpp Outdated
Copy link
Copy Markdown
Member

@jkotas jkotas left a comment

Choose a reason for hiding this comment

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

cc @janvorli

The goal here is to make the debugger contract for EH to be more similar between regular CoreCLR and NAOT.

// before any debugger notification fires. Assert this invariant in debug builds.
#ifdef _DEBUG
ExInfo* pTracker = pThread->GetExceptionState()->GetCurrentExceptionTracker();
if (pTracker != NULL && pTracker->m_exception != NULL && pThread->m_LastThrownObjectHandle != NULL)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are there any case where we have the exception stashed in m_LastThrownObjectHandle, but the exception tracker does not exist anymore?

I am wondering why we need m_LastThrownObjectHandle with the complicated system to keep it in sync with the tracker.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, m_LastThrownObjectHandle is read after the tracker is gone by EX_CATCH block via CLRLastThrownObjectException.

For the debugger path you, the tracker IS alive, but we need the handle for the MODE_ANY contract (or modify it). Removing LTO entirely would require reworking CLRLastThrownObjectException.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@janvorli Do you think it would be feasible to stop synchronizing m_LastThrownObjectHandle with pTracker->m_exception?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'll take a look

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

So, the m_LastThrownObjectHandle is needed to bridge the time when managed exception crosses runtime (on Windows also external native code). When SEH COM exception reaches a managed frame after the exception passed through native frames, the ProcessCLRException on Windows or the handler of the PAL_SEHException on Unix uses it to continue propagating the same managed exception object. It seems to me though that we could just create the handle (or set its value) when we are getting rid of the ExInfo. That would simplify the maintenance. But there may be some dragons hidden.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

It seems to me though that we could just create the handle (or set its value) when we are getting rid of the ExInfo

Yes, it is what I meant.

@max-charlamb
Copy link
Copy Markdown
Member Author

@EgorBot -amd --filter "Exceptions.Handling.CatchAndThrowOtherDeep*"

@max-charlamb

This comment was marked as outdated.

1 similar comment
@max-charlamb

This comment was marked as outdated.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions
Copy link
Copy Markdown
Contributor

🤖 Copilot Code Review — PR #127300

Note

This review was generated by GitHub Copilot using multi-model analysis (Claude Opus 4.6 primary + GPT-5.3-Codex cross-validation).

Holistic Assessment

Motivation: Well-justified. Replacing the GC handle (m_hThrowable) with a direct OBJECTREF (m_exception) eliminates ~5 interlocked handle alloc/destroy operations per exception throw, removes OOM fallback paths in SetThrowable, and aligns CoreCLR with NativeAOT's existing approach. The GC handle indirection was unnecessary since m_exception already existed on the ExInfo struct.

Approach: The approach is sound and mirrors NativeAOT's GcScanRootsWorker pattern (thread.cpp:569–573). Adding ExInfo chain scanning in ScanStackRoots is the correct way to report direct OBJECTREF fields that aren't covered by GCPROTECT frames or the normal stackwalk. Removing the double-reporting via GCPROTECT is also correct per CLR code guide §2.1.5.

Summary: ⚠️ Needs Human Review. The code is well-structured, thoroughly commented, and matches NativeAOT precedent. All GC correctness concerns I investigated check out. However, there is one behavioral change in debugger code that warrants human verification, and the (OBJECTHANDLE)&m_exception type-pun pattern used in several places, while functionally correct, is worth a maintainer's explicit blessing.


Detailed Findings

✅ GC Root Scanning — Correct and complete

The new ExInfo chain scanning in ScanStackRoots (gcenv.ee.cpp:209–220) correctly walks the full chain via GetPreviousExceptionTracker() (which returns m_pPrevNestedInfo). The fn(pRef, sc, 0) call with flags=0 is appropriate for a non-interior, non-pinned object reference, consistent with how NativeAOT reports the same field (EnumGcRef(pExceptionObj, GCRK_Object, ...)).

No GC window exists: The ExInfo constructor links into the chain (m_pCurrentTracker = this) in the member initializer body (exinfo.cpp:40) before m_exception is ever assigned a non-null value. The field is initialized to (Object*)NULL in the initializer list (line 20), so if a GC occurs before managed code assigns the real exception, the scanner simply sees null.

Flagged by: primary review + GPT-5.3-Codex (both agree)

✅ GCPROTECT Removal — Correct per CLR code guide

The three removed GCPROTECT_BEGIN/END blocks around exInfo.m_exception in HandleHardwareException, DispatchManagedException, and DispatchRethrownManagedException are correctly removed. Double-reporting the same GC slot (once via GCPROTECT, once via chain scanning) corrupts the GC's relocation logic. The comments replacing them are clear and cite the relevant section of the code guide.

The remaining GCPROTECT_BEGIN(throwable) / GCPROTECT_END() pair in DispatchManagedException (lines 1570/1635) is unrelated — it protects the local throwable parameter, not m_exception.

✅ DAC Pseudo-Handle Pattern — Correct for dump analysis

The DAC code (task.cpp, dacimpl.h) passes (OBJECTHANDLE)dac_cast<TADDR>(&exState->m_exception) as a "pseudo-handle." This is safe because:

  • During dump analysis, the target stack is frozen — the address is stable
  • ClrDataExceptionState treats this as an address-to-dereference, not a real GC handle
  • The lifetime matches: both the ExInfo and the stack address become invalid together when PopExInfos/ReleaseResources is called
  • The new comment in dacimpl.h (lines 3400–3406) clearly documents this contract

Flagged by: primary review + GPT-5.3-Codex (both agree safe)

✅ ObjC Interop — MODE_COOPERATIVE transition correct

The signature change from OBJECTHANDLE + MODE_PREEMPTIVE to OBJECTREF + MODE_COOPERATIVE is correct. The caller (SfiNextWorker) has MODE_COOPERATIVE in its contract (exceptionhandling.cpp:3899), so the OBJECTREF is valid. The GCPROTECT_BEGIN(throwableRef) on the function parameter in interoplibinterface_objc.cpp is valid — it protects the by-value copy of the OBJECTREF on the stack during the callback invocation.

✅ StackTraceInfo::AppendElement — Type-pun is functionally correct

The calls passing (OBJECTHANDLE)&pExInfo->m_exception to the OBJECTHANDLE overload of AppendElement work correctly because ObjectFromHandle simply dereferences the pointer (*(Object**)handle), and &m_exception points to a valid Object* on the stack. The preallocated exception check (IsPreallocatedExceptionObject(ObjectFromHandle(hThrowable))) at excep.cpp:2575 works identically.

⚠️ IsThreadExceptionNull — Behavioral change (needs human verification)

File: eedbginterfaceimpl.cpp:262–266

The old code checked only ExInfo::m_hThrowable (the current exception tracker's handle). The new code checks:

return pThread->IsThrowableNull() && pThread->IsLastThrownObjectNull();

This is a stricter check — the old code could return true (no exception) even when m_LastThrownObjectHandle was non-null (stale from a previous exception). The new code returns false in that case, potentially making the debugger think an exception is still active when only a stale m_LastThrownObjectHandle remains.

This may be more correct (the old code had a comment about falling back to m_LastThrownObjectHandle in GetThreadException, suggesting the two should be considered together), but it's a behavioral change in a debugger-facing API. A human reviewer should verify this doesn't cause spurious "exception active" signals in debugging scenarios.

Flagged by: primary review + GPT-5.3-Codex (both flagged)

✅ cDAC Contract Updates — Consistent and correct

The ExceptionInfo data class correctly transitions from ObjectHandle to TargetPointer, and the contracts (Exception_1.cs, Thread_1.cs) correctly compute the field address as a pseudo-handle using exceptionInfoAddr + offset. The nameof(Data.ExceptionInfo.ThrownObject) lookup will always succeed because the field is unconditionally initialized in the constructor.

✅ AsmOffsets — Field layout shift is mechanical

All offsets shift down by 8 bytes (64-bit) / 4 bytes (32-bit) due to the removed OBJECTHANDLE field. These offsets are verified at build time by static asserts in the native code (the AsmOffsets class exists specifically for this cross-validation purpose).

💡 Suggestion — Consider adding a comment on the type-pun pattern

The (OBJECTHANDLE)&pExInfo->m_exception pattern appears in 4 places (exceptionhandling.cpp ×3, excep.cpp ×1). While functionally correct, it relies on ObjectFromHandle being a simple pointer dereference. A brief comment at the first usage (or a helper like ObjectHandleFromRef(&ref)) would make the intent clearer and prevent future confusion. This is a follow-up suggestion, not a blocker.

Generated by Code Review for issue #127300 ·

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.

4 participants