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

Merged
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer
May 1, 2026
Merged

Remove ExInfo::m_hThrowable - use direct pointer for exception objects#127300
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer

Conversation

@max-charlamb

@max-charlambmax-charlamb commented Apr 22, 2026

Copy link
Copy Markdown
Member

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 section 2.1.5). Debug OBJECTREF tracking is satisfied via Thread::ObjectRefProtected in the ExInfo constructor.
  • Add ExInfo::GetThrowableAsPseudoHandle() - returns the target address of m_exception as a pseudo-handle (not a real GC handle table entry). Uses PTR_HOST_MEMBER_TADDR for correct DAC target address computation. The slot is updated during GC by the ExInfo chain scanner.
  • Remove GetThrowableAsHandle() - replaced by GetThrowableAsPseudoHandle() on ExInfo, ThreadExceptionState, and Thread
  • Remove SetThrowable() entirely - managed EH code writes m_exception directly; the SetThrowableErrorChecking enum and STEC_* constants are also removed.
  • Remove GetMyThread() - dead function with zero callers
  • Merge AppendElementImpl into AppendElement - only one caller remained after removing the OBJECTHANDLE overload. The merged function handles foreign exception semantics and preallocated exception checks.
  • Remove OBJECTHANDLE overload of AppendElement - all callers now pass OBJECTREF directly (including prestub.cpp and threads.cpp via ObjectFromHandle)
  • Fix StackTraceInfo::AppendElement preallocated-exception check: changed from IsPreallocatedExceptionHandle to IsPreallocatedExceptionObject(ObjectFromHandle(...))
  • 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: GetCurrentException reads from ExInfo first via GetThrowableAsPseudoHandle(), falls back to m_LastThrownObjectHandle. GetThreadException uses same pattern.
  • Update cDAC: ThrownObjectHandle -> ThrownObject (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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from db309be to e6688b3CompareApril 22, 2026 21:54
CopilotAI review requested due to automatic review settings April 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e6688b3 to 2812642CompareApril 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 2812642 to 9d54eecCompareApril 22, 2026 22:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/native/managed/cdac/tests/ThreadTests.cs
Comment threadsrc/native/managed/cdac/tests/ThreadTests.cs Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 9d54eec to e761d3eCompareApril 23, 2026 02:35
CopilotAI review requested due to automatic review settings April 23, 2026 03:01
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e761d3e to 34f7963CompareApril 23, 2026 03:01

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/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>
CopilotAI review requested due to automatic review settings April 24, 2026 01:38
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from adffa37 to 7aae554CompareApril 24, 2026 01:38

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/coreclr/vm/gcenv.ee.cpp
Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
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 marked this pull request as ready for review April 24, 2026 18:58

@janvorlijanvorli left a comment

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.

LGTM, thank you!

Comment threadsrc/coreclr/vm/excep.cpp Outdated
@janvorli

Copy link
Copy Markdown
Member

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

AppendElementImpl had only one caller after removing the OBJECTHANDLE
overload. Inline it into AppendElement and remove the separate function.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Comment threadsrc/coreclr/vm/threads.h Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 17:54
@github-actions

This comment has been minimized.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/debug/daccess/request.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

Verified on the private diagnostics suite

dac_cast<TADDR>(&m_exception) returns the host address in DAC builds,
but we need the target address. PTR_HOST_MEMBER_TADDR correctly
computes the target address of the field.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@max-charlamb
max-charlamb enabled auto-merge (squash) April 30, 2026 19:46
@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

🤖 Copilot Code Review — PR #127300

Holistic Assessment

Motivation: The PR removes the GC handle indirection (m_hThrowable) for exception objects in ExInfo, replacing it with a direct OBJECTREF that is GC-reported via ExInfo chain scanning in ScanStackRoots. This eliminates handle allocation/deallocation overhead, saves 8 bytes per ExInfo on 64-bit, and aligns CoreCLR with NativeAOT's exception handling pattern. The motivation is sound — handle-based exception storage was a legacy design.

Approach: The approach is well-reasoned. GC reporting is moved to an explicit scan of the ExInfo chain in ScanStackRoots, GCPROTECT is removed (to avoid double-reporting which corrupts GC relocation), and DAC/debugger consumers get a pseudo-handle (the target address of the m_exception field) that semantically behaves like a handle for read-through purposes. The cDAC and test updates are consistent with the native changes.

Summary: ⚠️ Needs Human Review. The core GC mechanism appears correct and the implementation is clean, but there are two subtle concerns around the debugger pseudo-handle contract that warrant human verification from someone familiar with the ICorDebug right-side and the ForceCatchHandlerFoundTable interaction.


Detailed Findings

✅ GC Correctness — ExInfo chain scanning is sound

The new scanning loop in gcenv.ee.cpp:209-220 correctly walks the ExInfo chain and reports each m_exception slot. Key verification:

  • m_exception is initialized to NULL in the ExInfo constructor member initializer list before the ExInfo is linked into the chain (line 40 of exinfo.cpp).
  • The GC's Promote function safely handles NULL object pointers (interface.cpp:1057is_in_find_object_range(NULL) returns false, causing early exit).
  • The constructor links the ExInfo into the chain (m_pCurrentTracker = this) before any exception object is stored, so there's no window where a non-null m_exception exists in an unscanned ExInfo.
  • Removal of GCPROTECT_BEGIN/END is correct since double-reporting the same slot corrupts GC relocation logic.
  • USE_CHECKED_OBJECTREFS debug registration (Thread::ObjectRefProtected) ensures checked builds validate the root.
  • The comment correctly references NativeAOT's GcScanRootsWorker as the analogous pattern.

✅ Cleanup Completeness — No leftover references

Verified: no remaining references to m_hThrowable, SetThrowable, STEC_All, SetThrowableErrorChecking, AppendElementImpl, or GetThrowableAsHandle exist in the codebase. The removal is thorough.

⚠️ Debugger Integration — ForceCatchHandlerFoundTable lookup with pseudo-handle (advisory, not merge-blocking)

In debugger.cpp:7870-7871:

OBJECTHANDLE objHandle = pThread->GetThrowableAsPseudoHandle();
OBJECTHANDLE retrievedHandle = m_pForceCatchHandlerFoundEventsTable->Lookup(objHandle);

The ForceCatchHandlerFoundSHashTraits::Equals and Hash functions (debugger.h:503-513) call ObjectFromHandle on both the stored handles (real long weak handles created via CreateLongWeakHandle) and the lookup key (pseudo-handle). Since ObjectFromHandle just dereferences *(Object**)handle, both a real handle and the pseudo-handle correctly resolve to the same Object* when the same exception is active. This works correctly.

However, this creates an implicit contract: the pseudo-handle must be dereferenceable whenever ShouldSendCatchHandlerFound is called. Since this only happens during active exception dispatch (when the ExInfo is on the stack and the thread is in COOP mode), this appears safe. But the coupling between a debugger hash table expecting real GC handles and a novel stack-address pseudo-handle is subtle.

Suggestion: Consider adding a brief comment at the ShouldSendCatchHandlerFound usage noting that the pseudo-handle is safe to pass to Lookup because ObjectFromHandle only dereferences the pointer (identical to dereferencing a real handle).

⚠️ DAC Pseudo-Handle Lifetime — GetCurrentException returns stack address to right side (advisory, not merge-blocking)

In dacdbiimpl.cpp:4958-4970, GetCurrentException returns the pseudo-handle (address of ExInfo::m_exception on the stack) as a VMPTR_OBJECTHANDLE to the debugger right side. Previously this was a GC handle table entry (long-lived heap memory).

Since DAC operates on point-in-time snapshots of a suspended process, this is safe for typical scenarios. However, if any right-side debugger code caches this handle value across process resume/suspend cycles, it could reference a dangling stack address (the ExInfo may have been popped). This is unlikely given the ICorDebug protocol re-queries state after resume, but warrants confirmation from someone familiar with the right-side implementation.

✅ Interop Changes — ObjC exception propagation correctly updated

The GetPropagatingExceptionCallback signature change from OBJECTHANDLE to OBJECTREF is correct. The caller in exceptionhandling.cpp:3957 passes pTopExInfo->m_exception directly. The callee properly GCPROTECT_BEGINs the OBJECTREF before making calls that could trigger GC. The mode contract correctly changed from MODE_PREEMPTIVE to MODE_COOPERATIVE.

✅ AsmOffsets — Field offset adjustments are consistent

Removing the 8-byte m_hThrowable field shifts subsequent field offsets down by 8 bytes on 64-bit (0xa8→0xa0, 0xb0→0xa8, etc.) and 4 bytes on 32-bit (0x5c→0x58, 0x60→0x5c, etc.). The AsmOffsets.cs changes reflect exactly this shift.

✅ cDAC Contract Updates — Correct adaptation

The cDAC changes correctly:

  • Change ExceptionInfo.ThrownObjectHandle (ObjectHandle) to ThrownObject (pointer)
  • Compute the pseudo-handle by adding the field offset to the ExInfo address
  • Short-circuit to null when ThrownObject == TargetPointer.Null
  • Tests are updated to verify read-through semantics (asserting the pointer dereferences to the expected object)

✅ Thread Safety — No races in the scanning path

The ExInfo chain is only modified on the owning thread (exception dispatch is per-thread), and GC scanning only occurs when threads are suspended. There is no race between writing m_exception and reading it during GC.

💡 Minor — prestub.cpp AppendElement calls

In prestub.cpp:1929 and 2134, the code now does StackTraceInfo::AppendElement(ObjectFromHandle(ohThrowable), ...). The handle here is LastThrownObjectHandle() (a real GC handle, not a pseudo-handle), so this is correct. Just noting the pattern is intentionally different from the ExInfo path.

Generated by Code Review for issue #127300 ·

The ExInfo::m_exception slot is GC-reported via the ExInfo chain scanner in ScanStackRoots (gcenv.ee.cpp). Reporting the same OBJECTREF location to the GC twice (once via GCPROTECT, once via the chain scanner) corrupts the relocate phase: the slot gets relocated twice, producing a garbage pointer that AVs on the next read.
This was missed in the earlier conversion of the three sites in exceptionhandling.cpp. It was reproducible on x86 checked builds running JitTest_throw_SEH, where SEH (hardware faults) routes through HandleManagedFault.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 04:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated no new comments.

@max-charlamb

Copy link
Copy Markdown
MemberAuthor

/ba-g Test failures are unrelated to PR changes

@max-charlamb
max-charlamb merged commit dc3d5a0 into mainMay 1, 2026
107 of 119 checks passed
@max-charlamb
max-charlamb deleted the dev/max-charlamb/exception-direct-pointer branch May 1, 2026 14:30
max-charlamb added a commit to max-charlamb/runtime that referenced this pull request May 1, 2026
Two changes responding to PR review feedback:
1. Remove dead CEEInfo::HandleException.
The function has been unreachable since 2016 (commit 4d9f4b8 `Remove SEH interactions between the JIT and the EE'') which replaced the old ICorJitInfo::FilterException/HandleException pair with runWithErrorTrap. The function is private, non-virtual, not part of the ICorJitInfo interface, and has zero callers in coreclr, the JIT, the AOT thunks, or SuperPMI (the SuperPMI Packet_HandleException slot is commented out). Removing it also retires the long-stale comment about `sync between the LTO and the exception tracker'' that pre-dates the ExInfo redesign and the lazy-LTO model from dotnet#127300/dotnet#127649.
2. Reorder declarations in threads.h so SetLastThrownObject precedes SetSOForLastThrownObject, matching the order of the definitions in threads.cpp.
Also update an unrelated stale comment in ExInfo::PopExInfos: the `unmanaged thread'' rationale is incorrect because both UMThunkUnwindFrameChainHandler and CallDescrWorkerUnwindFrameChainHandler short-circuit unmanaged threads before reaching PopExInfos, and the function carries a MODE_COOPERATIVE contract.
No behavior change.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hoyosjs added a commit that referenced this pull request May 5, 2026
…xception dispatch (#127741)
After #127300 removed ExInfo::m_hThrowable and SetThrowable(),
Thread::m_LastThrownObjectHandle is no longer updated during active
exception dispatch. This causes a staleclastThrownObjectHandle to ge
returned by GetThreadData when a debugger breaks into the target
mid-dispatch.
When debugging a .NET 11 process with SOS (e.g. via dotnet-dump or
WinDbg), the following commands fail if the debuggee is stopped during
exception dispatch (for example, with SXE CLR):
- !PrintException — reports "Invalid exception object" because it
dereferences the stale handle and gets a null or recycled pointer
- !Threads — shows no exception column for threads that are actively
throwing
- !clrstack -a with exception context — may show incomplete or missing
exception info
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 1, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@janvorli@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

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

Merged
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer
May 1, 2026
Merged

Remove ExInfo::m_hThrowable - use direct pointer for exception objects#127300
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer

Conversation

@max-charlamb

@max-charlambmax-charlamb commented Apr 22, 2026

Copy link
Copy Markdown
Member

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 section 2.1.5). Debug OBJECTREF tracking is satisfied via Thread::ObjectRefProtected in the ExInfo constructor.
  • Add ExInfo::GetThrowableAsPseudoHandle() - returns the target address of m_exception as a pseudo-handle (not a real GC handle table entry). Uses PTR_HOST_MEMBER_TADDR for correct DAC target address computation. The slot is updated during GC by the ExInfo chain scanner.
  • Remove GetThrowableAsHandle() - replaced by GetThrowableAsPseudoHandle() on ExInfo, ThreadExceptionState, and Thread
  • Remove SetThrowable() entirely - managed EH code writes m_exception directly; the SetThrowableErrorChecking enum and STEC_* constants are also removed.
  • Remove GetMyThread() - dead function with zero callers
  • Merge AppendElementImpl into AppendElement - only one caller remained after removing the OBJECTHANDLE overload. The merged function handles foreign exception semantics and preallocated exception checks.
  • Remove OBJECTHANDLE overload of AppendElement - all callers now pass OBJECTREF directly (including prestub.cpp and threads.cpp via ObjectFromHandle)
  • Fix StackTraceInfo::AppendElement preallocated-exception check: changed from IsPreallocatedExceptionHandle to IsPreallocatedExceptionObject(ObjectFromHandle(...))
  • 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: GetCurrentException reads from ExInfo first via GetThrowableAsPseudoHandle(), falls back to m_LastThrownObjectHandle. GetThreadException uses same pattern.
  • Update cDAC: ThrownObjectHandle -> ThrownObject (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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from db309be to e6688b3CompareApril 22, 2026 21:54
CopilotAI review requested due to automatic review settings April 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e6688b3 to 2812642CompareApril 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 2812642 to 9d54eecCompareApril 22, 2026 22:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/native/managed/cdac/tests/ThreadTests.cs
Comment threadsrc/native/managed/cdac/tests/ThreadTests.cs Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 9d54eec to e761d3eCompareApril 23, 2026 02:35
CopilotAI review requested due to automatic review settings April 23, 2026 03:01
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e761d3e to 34f7963CompareApril 23, 2026 03:01

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/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>
CopilotAI review requested due to automatic review settings April 24, 2026 01:38
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from adffa37 to 7aae554CompareApril 24, 2026 01:38

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/coreclr/vm/gcenv.ee.cpp
Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
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 marked this pull request as ready for review April 24, 2026 18:58

@janvorlijanvorli left a comment

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.

LGTM, thank you!

Comment threadsrc/coreclr/vm/excep.cpp Outdated
@janvorli

Copy link
Copy Markdown
Member

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

AppendElementImpl had only one caller after removing the OBJECTHANDLE
overload. Inline it into AppendElement and remove the separate function.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Comment threadsrc/coreclr/vm/threads.h Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 17:54
@github-actions

This comment has been minimized.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/debug/daccess/request.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

Verified on the private diagnostics suite

dac_cast<TADDR>(&m_exception) returns the host address in DAC builds,
but we need the target address. PTR_HOST_MEMBER_TADDR correctly
computes the target address of the field.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@max-charlamb
max-charlamb enabled auto-merge (squash) April 30, 2026 19:46
@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

🤖 Copilot Code Review — PR #127300

Holistic Assessment

Motivation: The PR removes the GC handle indirection (m_hThrowable) for exception objects in ExInfo, replacing it with a direct OBJECTREF that is GC-reported via ExInfo chain scanning in ScanStackRoots. This eliminates handle allocation/deallocation overhead, saves 8 bytes per ExInfo on 64-bit, and aligns CoreCLR with NativeAOT's exception handling pattern. The motivation is sound — handle-based exception storage was a legacy design.

Approach: The approach is well-reasoned. GC reporting is moved to an explicit scan of the ExInfo chain in ScanStackRoots, GCPROTECT is removed (to avoid double-reporting which corrupts GC relocation), and DAC/debugger consumers get a pseudo-handle (the target address of the m_exception field) that semantically behaves like a handle for read-through purposes. The cDAC and test updates are consistent with the native changes.

Summary: ⚠️ Needs Human Review. The core GC mechanism appears correct and the implementation is clean, but there are two subtle concerns around the debugger pseudo-handle contract that warrant human verification from someone familiar with the ICorDebug right-side and the ForceCatchHandlerFoundTable interaction.


Detailed Findings

✅ GC Correctness — ExInfo chain scanning is sound

The new scanning loop in gcenv.ee.cpp:209-220 correctly walks the ExInfo chain and reports each m_exception slot. Key verification:

  • m_exception is initialized to NULL in the ExInfo constructor member initializer list before the ExInfo is linked into the chain (line 40 of exinfo.cpp).
  • The GC's Promote function safely handles NULL object pointers (interface.cpp:1057is_in_find_object_range(NULL) returns false, causing early exit).
  • The constructor links the ExInfo into the chain (m_pCurrentTracker = this) before any exception object is stored, so there's no window where a non-null m_exception exists in an unscanned ExInfo.
  • Removal of GCPROTECT_BEGIN/END is correct since double-reporting the same slot corrupts GC relocation logic.
  • USE_CHECKED_OBJECTREFS debug registration (Thread::ObjectRefProtected) ensures checked builds validate the root.
  • The comment correctly references NativeAOT's GcScanRootsWorker as the analogous pattern.

✅ Cleanup Completeness — No leftover references

Verified: no remaining references to m_hThrowable, SetThrowable, STEC_All, SetThrowableErrorChecking, AppendElementImpl, or GetThrowableAsHandle exist in the codebase. The removal is thorough.

⚠️ Debugger Integration — ForceCatchHandlerFoundTable lookup with pseudo-handle (advisory, not merge-blocking)

In debugger.cpp:7870-7871:

OBJECTHANDLE objHandle = pThread->GetThrowableAsPseudoHandle();
OBJECTHANDLE retrievedHandle = m_pForceCatchHandlerFoundEventsTable->Lookup(objHandle);

The ForceCatchHandlerFoundSHashTraits::Equals and Hash functions (debugger.h:503-513) call ObjectFromHandle on both the stored handles (real long weak handles created via CreateLongWeakHandle) and the lookup key (pseudo-handle). Since ObjectFromHandle just dereferences *(Object**)handle, both a real handle and the pseudo-handle correctly resolve to the same Object* when the same exception is active. This works correctly.

However, this creates an implicit contract: the pseudo-handle must be dereferenceable whenever ShouldSendCatchHandlerFound is called. Since this only happens during active exception dispatch (when the ExInfo is on the stack and the thread is in COOP mode), this appears safe. But the coupling between a debugger hash table expecting real GC handles and a novel stack-address pseudo-handle is subtle.

Suggestion: Consider adding a brief comment at the ShouldSendCatchHandlerFound usage noting that the pseudo-handle is safe to pass to Lookup because ObjectFromHandle only dereferences the pointer (identical to dereferencing a real handle).

⚠️ DAC Pseudo-Handle Lifetime — GetCurrentException returns stack address to right side (advisory, not merge-blocking)

In dacdbiimpl.cpp:4958-4970, GetCurrentException returns the pseudo-handle (address of ExInfo::m_exception on the stack) as a VMPTR_OBJECTHANDLE to the debugger right side. Previously this was a GC handle table entry (long-lived heap memory).

Since DAC operates on point-in-time snapshots of a suspended process, this is safe for typical scenarios. However, if any right-side debugger code caches this handle value across process resume/suspend cycles, it could reference a dangling stack address (the ExInfo may have been popped). This is unlikely given the ICorDebug protocol re-queries state after resume, but warrants confirmation from someone familiar with the right-side implementation.

✅ Interop Changes — ObjC exception propagation correctly updated

The GetPropagatingExceptionCallback signature change from OBJECTHANDLE to OBJECTREF is correct. The caller in exceptionhandling.cpp:3957 passes pTopExInfo->m_exception directly. The callee properly GCPROTECT_BEGINs the OBJECTREF before making calls that could trigger GC. The mode contract correctly changed from MODE_PREEMPTIVE to MODE_COOPERATIVE.

✅ AsmOffsets — Field offset adjustments are consistent

Removing the 8-byte m_hThrowable field shifts subsequent field offsets down by 8 bytes on 64-bit (0xa8→0xa0, 0xb0→0xa8, etc.) and 4 bytes on 32-bit (0x5c→0x58, 0x60→0x5c, etc.). The AsmOffsets.cs changes reflect exactly this shift.

✅ cDAC Contract Updates — Correct adaptation

The cDAC changes correctly:

  • Change ExceptionInfo.ThrownObjectHandle (ObjectHandle) to ThrownObject (pointer)
  • Compute the pseudo-handle by adding the field offset to the ExInfo address
  • Short-circuit to null when ThrownObject == TargetPointer.Null
  • Tests are updated to verify read-through semantics (asserting the pointer dereferences to the expected object)

✅ Thread Safety — No races in the scanning path

The ExInfo chain is only modified on the owning thread (exception dispatch is per-thread), and GC scanning only occurs when threads are suspended. There is no race between writing m_exception and reading it during GC.

💡 Minor — prestub.cpp AppendElement calls

In prestub.cpp:1929 and 2134, the code now does StackTraceInfo::AppendElement(ObjectFromHandle(ohThrowable), ...). The handle here is LastThrownObjectHandle() (a real GC handle, not a pseudo-handle), so this is correct. Just noting the pattern is intentionally different from the ExInfo path.

Generated by Code Review for issue #127300 ·

The ExInfo::m_exception slot is GC-reported via the ExInfo chain scanner in ScanStackRoots (gcenv.ee.cpp). Reporting the same OBJECTREF location to the GC twice (once via GCPROTECT, once via the chain scanner) corrupts the relocate phase: the slot gets relocated twice, producing a garbage pointer that AVs on the next read.
This was missed in the earlier conversion of the three sites in exceptionhandling.cpp. It was reproducible on x86 checked builds running JitTest_throw_SEH, where SEH (hardware faults) routes through HandleManagedFault.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 04:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated no new comments.

@max-charlamb

Copy link
Copy Markdown
MemberAuthor

/ba-g Test failures are unrelated to PR changes

@max-charlamb
max-charlamb merged commit dc3d5a0 into mainMay 1, 2026
107 of 119 checks passed
@max-charlamb
max-charlamb deleted the dev/max-charlamb/exception-direct-pointer branch May 1, 2026 14:30
max-charlamb added a commit to max-charlamb/runtime that referenced this pull request May 1, 2026
Two changes responding to PR review feedback:
1. Remove dead CEEInfo::HandleException.
The function has been unreachable since 2016 (commit 4d9f4b8 `Remove SEH interactions between the JIT and the EE'') which replaced the old ICorJitInfo::FilterException/HandleException pair with runWithErrorTrap. The function is private, non-virtual, not part of the ICorJitInfo interface, and has zero callers in coreclr, the JIT, the AOT thunks, or SuperPMI (the SuperPMI Packet_HandleException slot is commented out). Removing it also retires the long-stale comment about `sync between the LTO and the exception tracker'' that pre-dates the ExInfo redesign and the lazy-LTO model from dotnet#127300/dotnet#127649.
2. Reorder declarations in threads.h so SetLastThrownObject precedes SetSOForLastThrownObject, matching the order of the definitions in threads.cpp.
Also update an unrelated stale comment in ExInfo::PopExInfos: the `unmanaged thread'' rationale is incorrect because both UMThunkUnwindFrameChainHandler and CallDescrWorkerUnwindFrameChainHandler short-circuit unmanaged threads before reaching PopExInfos, and the function carries a MODE_COOPERATIVE contract.
No behavior change.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hoyosjs added a commit that referenced this pull request May 5, 2026
…xception dispatch (#127741)
After #127300 removed ExInfo::m_hThrowable and SetThrowable(),
Thread::m_LastThrownObjectHandle is no longer updated during active
exception dispatch. This causes a staleclastThrownObjectHandle to ge
returned by GetThreadData when a debugger breaks into the target
mid-dispatch.
When debugging a .NET 11 process with SOS (e.g. via dotnet-dump or
WinDbg), the following commands fail if the debuggee is stopped during
exception dispatch (for example, with SXE CLR):
- !PrintException — reports "Invalid exception object" because it
dereferences the stale handle and gets a null or recycled pointer
- !Threads — shows no exception column for threads that are actively
throwing
- !clrstack -a with exception context — may show incomplete or missing
exception info
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 1, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@janvorli@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer
May 1, 2026
Merged

Remove ExInfo::m_hThrowable - use direct pointer for exception objects#127300
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer

Conversation

@max-charlamb

@max-charlambmax-charlamb commented Apr 22, 2026

Copy link
Copy Markdown
Member

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 section 2.1.5). Debug OBJECTREF tracking is satisfied via Thread::ObjectRefProtected in the ExInfo constructor.
  • Add ExInfo::GetThrowableAsPseudoHandle() - returns the target address of m_exception as a pseudo-handle (not a real GC handle table entry). Uses PTR_HOST_MEMBER_TADDR for correct DAC target address computation. The slot is updated during GC by the ExInfo chain scanner.
  • Remove GetThrowableAsHandle() - replaced by GetThrowableAsPseudoHandle() on ExInfo, ThreadExceptionState, and Thread
  • Remove SetThrowable() entirely - managed EH code writes m_exception directly; the SetThrowableErrorChecking enum and STEC_* constants are also removed.
  • Remove GetMyThread() - dead function with zero callers
  • Merge AppendElementImpl into AppendElement - only one caller remained after removing the OBJECTHANDLE overload. The merged function handles foreign exception semantics and preallocated exception checks.
  • Remove OBJECTHANDLE overload of AppendElement - all callers now pass OBJECTREF directly (including prestub.cpp and threads.cpp via ObjectFromHandle)
  • Fix StackTraceInfo::AppendElement preallocated-exception check: changed from IsPreallocatedExceptionHandle to IsPreallocatedExceptionObject(ObjectFromHandle(...))
  • 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: GetCurrentException reads from ExInfo first via GetThrowableAsPseudoHandle(), falls back to m_LastThrownObjectHandle. GetThreadException uses same pattern.
  • Update cDAC: ThrownObjectHandle -> ThrownObject (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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from db309be to e6688b3CompareApril 22, 2026 21:54
CopilotAI review requested due to automatic review settings April 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e6688b3 to 2812642CompareApril 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 2812642 to 9d54eecCompareApril 22, 2026 22:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/native/managed/cdac/tests/ThreadTests.cs
Comment threadsrc/native/managed/cdac/tests/ThreadTests.cs Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 9d54eec to e761d3eCompareApril 23, 2026 02:35
CopilotAI review requested due to automatic review settings April 23, 2026 03:01
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e761d3e to 34f7963CompareApril 23, 2026 03:01

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/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>
CopilotAI review requested due to automatic review settings April 24, 2026 01:38
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from adffa37 to 7aae554CompareApril 24, 2026 01:38

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/coreclr/vm/gcenv.ee.cpp
Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
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 marked this pull request as ready for review April 24, 2026 18:58

@janvorlijanvorli left a comment

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.

LGTM, thank you!

Comment threadsrc/coreclr/vm/excep.cpp Outdated
@janvorli

Copy link
Copy Markdown
Member

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

AppendElementImpl had only one caller after removing the OBJECTHANDLE
overload. Inline it into AppendElement and remove the separate function.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Comment threadsrc/coreclr/vm/threads.h Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 17:54
@github-actions

This comment has been minimized.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/debug/daccess/request.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

Verified on the private diagnostics suite

dac_cast<TADDR>(&m_exception) returns the host address in DAC builds,
but we need the target address. PTR_HOST_MEMBER_TADDR correctly
computes the target address of the field.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@max-charlamb
max-charlamb enabled auto-merge (squash) April 30, 2026 19:46
@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

🤖 Copilot Code Review — PR #127300

Holistic Assessment

Motivation: The PR removes the GC handle indirection (m_hThrowable) for exception objects in ExInfo, replacing it with a direct OBJECTREF that is GC-reported via ExInfo chain scanning in ScanStackRoots. This eliminates handle allocation/deallocation overhead, saves 8 bytes per ExInfo on 64-bit, and aligns CoreCLR with NativeAOT's exception handling pattern. The motivation is sound — handle-based exception storage was a legacy design.

Approach: The approach is well-reasoned. GC reporting is moved to an explicit scan of the ExInfo chain in ScanStackRoots, GCPROTECT is removed (to avoid double-reporting which corrupts GC relocation), and DAC/debugger consumers get a pseudo-handle (the target address of the m_exception field) that semantically behaves like a handle for read-through purposes. The cDAC and test updates are consistent with the native changes.

Summary: ⚠️ Needs Human Review. The core GC mechanism appears correct and the implementation is clean, but there are two subtle concerns around the debugger pseudo-handle contract that warrant human verification from someone familiar with the ICorDebug right-side and the ForceCatchHandlerFoundTable interaction.


Detailed Findings

✅ GC Correctness — ExInfo chain scanning is sound

The new scanning loop in gcenv.ee.cpp:209-220 correctly walks the ExInfo chain and reports each m_exception slot. Key verification:

  • m_exception is initialized to NULL in the ExInfo constructor member initializer list before the ExInfo is linked into the chain (line 40 of exinfo.cpp).
  • The GC's Promote function safely handles NULL object pointers (interface.cpp:1057is_in_find_object_range(NULL) returns false, causing early exit).
  • The constructor links the ExInfo into the chain (m_pCurrentTracker = this) before any exception object is stored, so there's no window where a non-null m_exception exists in an unscanned ExInfo.
  • Removal of GCPROTECT_BEGIN/END is correct since double-reporting the same slot corrupts GC relocation logic.
  • USE_CHECKED_OBJECTREFS debug registration (Thread::ObjectRefProtected) ensures checked builds validate the root.
  • The comment correctly references NativeAOT's GcScanRootsWorker as the analogous pattern.

✅ Cleanup Completeness — No leftover references

Verified: no remaining references to m_hThrowable, SetThrowable, STEC_All, SetThrowableErrorChecking, AppendElementImpl, or GetThrowableAsHandle exist in the codebase. The removal is thorough.

⚠️ Debugger Integration — ForceCatchHandlerFoundTable lookup with pseudo-handle (advisory, not merge-blocking)

In debugger.cpp:7870-7871:

OBJECTHANDLE objHandle = pThread->GetThrowableAsPseudoHandle();
OBJECTHANDLE retrievedHandle = m_pForceCatchHandlerFoundEventsTable->Lookup(objHandle);

The ForceCatchHandlerFoundSHashTraits::Equals and Hash functions (debugger.h:503-513) call ObjectFromHandle on both the stored handles (real long weak handles created via CreateLongWeakHandle) and the lookup key (pseudo-handle). Since ObjectFromHandle just dereferences *(Object**)handle, both a real handle and the pseudo-handle correctly resolve to the same Object* when the same exception is active. This works correctly.

However, this creates an implicit contract: the pseudo-handle must be dereferenceable whenever ShouldSendCatchHandlerFound is called. Since this only happens during active exception dispatch (when the ExInfo is on the stack and the thread is in COOP mode), this appears safe. But the coupling between a debugger hash table expecting real GC handles and a novel stack-address pseudo-handle is subtle.

Suggestion: Consider adding a brief comment at the ShouldSendCatchHandlerFound usage noting that the pseudo-handle is safe to pass to Lookup because ObjectFromHandle only dereferences the pointer (identical to dereferencing a real handle).

⚠️ DAC Pseudo-Handle Lifetime — GetCurrentException returns stack address to right side (advisory, not merge-blocking)

In dacdbiimpl.cpp:4958-4970, GetCurrentException returns the pseudo-handle (address of ExInfo::m_exception on the stack) as a VMPTR_OBJECTHANDLE to the debugger right side. Previously this was a GC handle table entry (long-lived heap memory).

Since DAC operates on point-in-time snapshots of a suspended process, this is safe for typical scenarios. However, if any right-side debugger code caches this handle value across process resume/suspend cycles, it could reference a dangling stack address (the ExInfo may have been popped). This is unlikely given the ICorDebug protocol re-queries state after resume, but warrants confirmation from someone familiar with the right-side implementation.

✅ Interop Changes — ObjC exception propagation correctly updated

The GetPropagatingExceptionCallback signature change from OBJECTHANDLE to OBJECTREF is correct. The caller in exceptionhandling.cpp:3957 passes pTopExInfo->m_exception directly. The callee properly GCPROTECT_BEGINs the OBJECTREF before making calls that could trigger GC. The mode contract correctly changed from MODE_PREEMPTIVE to MODE_COOPERATIVE.

✅ AsmOffsets — Field offset adjustments are consistent

Removing the 8-byte m_hThrowable field shifts subsequent field offsets down by 8 bytes on 64-bit (0xa8→0xa0, 0xb0→0xa8, etc.) and 4 bytes on 32-bit (0x5c→0x58, 0x60→0x5c, etc.). The AsmOffsets.cs changes reflect exactly this shift.

✅ cDAC Contract Updates — Correct adaptation

The cDAC changes correctly:

  • Change ExceptionInfo.ThrownObjectHandle (ObjectHandle) to ThrownObject (pointer)
  • Compute the pseudo-handle by adding the field offset to the ExInfo address
  • Short-circuit to null when ThrownObject == TargetPointer.Null
  • Tests are updated to verify read-through semantics (asserting the pointer dereferences to the expected object)

✅ Thread Safety — No races in the scanning path

The ExInfo chain is only modified on the owning thread (exception dispatch is per-thread), and GC scanning only occurs when threads are suspended. There is no race between writing m_exception and reading it during GC.

💡 Minor — prestub.cpp AppendElement calls

In prestub.cpp:1929 and 2134, the code now does StackTraceInfo::AppendElement(ObjectFromHandle(ohThrowable), ...). The handle here is LastThrownObjectHandle() (a real GC handle, not a pseudo-handle), so this is correct. Just noting the pattern is intentionally different from the ExInfo path.

Generated by Code Review for issue #127300 ·

The ExInfo::m_exception slot is GC-reported via the ExInfo chain scanner in ScanStackRoots (gcenv.ee.cpp). Reporting the same OBJECTREF location to the GC twice (once via GCPROTECT, once via the chain scanner) corrupts the relocate phase: the slot gets relocated twice, producing a garbage pointer that AVs on the next read.
This was missed in the earlier conversion of the three sites in exceptionhandling.cpp. It was reproducible on x86 checked builds running JitTest_throw_SEH, where SEH (hardware faults) routes through HandleManagedFault.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 04:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated no new comments.

@max-charlamb

Copy link
Copy Markdown
MemberAuthor

/ba-g Test failures are unrelated to PR changes

@max-charlamb
max-charlamb merged commit dc3d5a0 into mainMay 1, 2026
107 of 119 checks passed
@max-charlamb
max-charlamb deleted the dev/max-charlamb/exception-direct-pointer branch May 1, 2026 14:30
max-charlamb added a commit to max-charlamb/runtime that referenced this pull request May 1, 2026
Two changes responding to PR review feedback:
1. Remove dead CEEInfo::HandleException.
The function has been unreachable since 2016 (commit 4d9f4b8 `Remove SEH interactions between the JIT and the EE'') which replaced the old ICorJitInfo::FilterException/HandleException pair with runWithErrorTrap. The function is private, non-virtual, not part of the ICorJitInfo interface, and has zero callers in coreclr, the JIT, the AOT thunks, or SuperPMI (the SuperPMI Packet_HandleException slot is commented out). Removing it also retires the long-stale comment about `sync between the LTO and the exception tracker'' that pre-dates the ExInfo redesign and the lazy-LTO model from dotnet#127300/dotnet#127649.
2. Reorder declarations in threads.h so SetLastThrownObject precedes SetSOForLastThrownObject, matching the order of the definitions in threads.cpp.
Also update an unrelated stale comment in ExInfo::PopExInfos: the `unmanaged thread'' rationale is incorrect because both UMThunkUnwindFrameChainHandler and CallDescrWorkerUnwindFrameChainHandler short-circuit unmanaged threads before reaching PopExInfos, and the function carries a MODE_COOPERATIVE contract.
No behavior change.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hoyosjs added a commit that referenced this pull request May 5, 2026
…xception dispatch (#127741)
After #127300 removed ExInfo::m_hThrowable and SetThrowable(),
Thread::m_LastThrownObjectHandle is no longer updated during active
exception dispatch. This causes a staleclastThrownObjectHandle to ge
returned by GetThreadData when a debugger breaks into the target
mid-dispatch.
When debugging a .NET 11 process with SOS (e.g. via dotnet-dump or
WinDbg), the following commands fail if the debuggee is stopped during
exception dispatch (for example, with SXE CLR):
- !PrintException — reports "Invalid exception object" because it
dereferences the stale handle and gets a null or recycled pointer
- !Threads — shows no exception column for threads that are actively
throwing
- !clrstack -a with exception context — may show incomplete or missing
exception info
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 1, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@janvorli@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer
May 1, 2026
Merged

Remove ExInfo::m_hThrowable - use direct pointer for exception objects#127300
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer

Conversation

@max-charlamb

@max-charlambmax-charlamb commented Apr 22, 2026

Copy link
Copy Markdown
Member

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 section 2.1.5). Debug OBJECTREF tracking is satisfied via Thread::ObjectRefProtected in the ExInfo constructor.
  • Add ExInfo::GetThrowableAsPseudoHandle() - returns the target address of m_exception as a pseudo-handle (not a real GC handle table entry). Uses PTR_HOST_MEMBER_TADDR for correct DAC target address computation. The slot is updated during GC by the ExInfo chain scanner.
  • Remove GetThrowableAsHandle() - replaced by GetThrowableAsPseudoHandle() on ExInfo, ThreadExceptionState, and Thread
  • Remove SetThrowable() entirely - managed EH code writes m_exception directly; the SetThrowableErrorChecking enum and STEC_* constants are also removed.
  • Remove GetMyThread() - dead function with zero callers
  • Merge AppendElementImpl into AppendElement - only one caller remained after removing the OBJECTHANDLE overload. The merged function handles foreign exception semantics and preallocated exception checks.
  • Remove OBJECTHANDLE overload of AppendElement - all callers now pass OBJECTREF directly (including prestub.cpp and threads.cpp via ObjectFromHandle)
  • Fix StackTraceInfo::AppendElement preallocated-exception check: changed from IsPreallocatedExceptionHandle to IsPreallocatedExceptionObject(ObjectFromHandle(...))
  • 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: GetCurrentException reads from ExInfo first via GetThrowableAsPseudoHandle(), falls back to m_LastThrownObjectHandle. GetThreadException uses same pattern.
  • Update cDAC: ThrownObjectHandle -> ThrownObject (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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from db309be to e6688b3CompareApril 22, 2026 21:54
CopilotAI review requested due to automatic review settings April 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e6688b3 to 2812642CompareApril 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 2812642 to 9d54eecCompareApril 22, 2026 22:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/native/managed/cdac/tests/ThreadTests.cs
Comment threadsrc/native/managed/cdac/tests/ThreadTests.cs Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 9d54eec to e761d3eCompareApril 23, 2026 02:35
CopilotAI review requested due to automatic review settings April 23, 2026 03:01
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e761d3e to 34f7963CompareApril 23, 2026 03:01

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/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>
CopilotAI review requested due to automatic review settings April 24, 2026 01:38
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from adffa37 to 7aae554CompareApril 24, 2026 01:38

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/coreclr/vm/gcenv.ee.cpp
Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
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 marked this pull request as ready for review April 24, 2026 18:58

@janvorlijanvorli left a comment

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.

LGTM, thank you!

Comment threadsrc/coreclr/vm/excep.cpp Outdated
@janvorli

Copy link
Copy Markdown
Member

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

AppendElementImpl had only one caller after removing the OBJECTHANDLE
overload. Inline it into AppendElement and remove the separate function.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Comment threadsrc/coreclr/vm/threads.h Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 17:54
@github-actions

This comment has been minimized.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/debug/daccess/request.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

Verified on the private diagnostics suite

dac_cast<TADDR>(&m_exception) returns the host address in DAC builds,
but we need the target address. PTR_HOST_MEMBER_TADDR correctly
computes the target address of the field.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@max-charlamb
max-charlamb enabled auto-merge (squash) April 30, 2026 19:46
@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

🤖 Copilot Code Review — PR #127300

Holistic Assessment

Motivation: The PR removes the GC handle indirection (m_hThrowable) for exception objects in ExInfo, replacing it with a direct OBJECTREF that is GC-reported via ExInfo chain scanning in ScanStackRoots. This eliminates handle allocation/deallocation overhead, saves 8 bytes per ExInfo on 64-bit, and aligns CoreCLR with NativeAOT's exception handling pattern. The motivation is sound — handle-based exception storage was a legacy design.

Approach: The approach is well-reasoned. GC reporting is moved to an explicit scan of the ExInfo chain in ScanStackRoots, GCPROTECT is removed (to avoid double-reporting which corrupts GC relocation), and DAC/debugger consumers get a pseudo-handle (the target address of the m_exception field) that semantically behaves like a handle for read-through purposes. The cDAC and test updates are consistent with the native changes.

Summary: ⚠️ Needs Human Review. The core GC mechanism appears correct and the implementation is clean, but there are two subtle concerns around the debugger pseudo-handle contract that warrant human verification from someone familiar with the ICorDebug right-side and the ForceCatchHandlerFoundTable interaction.


Detailed Findings

✅ GC Correctness — ExInfo chain scanning is sound

The new scanning loop in gcenv.ee.cpp:209-220 correctly walks the ExInfo chain and reports each m_exception slot. Key verification:

  • m_exception is initialized to NULL in the ExInfo constructor member initializer list before the ExInfo is linked into the chain (line 40 of exinfo.cpp).
  • The GC's Promote function safely handles NULL object pointers (interface.cpp:1057is_in_find_object_range(NULL) returns false, causing early exit).
  • The constructor links the ExInfo into the chain (m_pCurrentTracker = this) before any exception object is stored, so there's no window where a non-null m_exception exists in an unscanned ExInfo.
  • Removal of GCPROTECT_BEGIN/END is correct since double-reporting the same slot corrupts GC relocation logic.
  • USE_CHECKED_OBJECTREFS debug registration (Thread::ObjectRefProtected) ensures checked builds validate the root.
  • The comment correctly references NativeAOT's GcScanRootsWorker as the analogous pattern.

✅ Cleanup Completeness — No leftover references

Verified: no remaining references to m_hThrowable, SetThrowable, STEC_All, SetThrowableErrorChecking, AppendElementImpl, or GetThrowableAsHandle exist in the codebase. The removal is thorough.

⚠️ Debugger Integration — ForceCatchHandlerFoundTable lookup with pseudo-handle (advisory, not merge-blocking)

In debugger.cpp:7870-7871:

OBJECTHANDLE objHandle = pThread->GetThrowableAsPseudoHandle();
OBJECTHANDLE retrievedHandle = m_pForceCatchHandlerFoundEventsTable->Lookup(objHandle);

The ForceCatchHandlerFoundSHashTraits::Equals and Hash functions (debugger.h:503-513) call ObjectFromHandle on both the stored handles (real long weak handles created via CreateLongWeakHandle) and the lookup key (pseudo-handle). Since ObjectFromHandle just dereferences *(Object**)handle, both a real handle and the pseudo-handle correctly resolve to the same Object* when the same exception is active. This works correctly.

However, this creates an implicit contract: the pseudo-handle must be dereferenceable whenever ShouldSendCatchHandlerFound is called. Since this only happens during active exception dispatch (when the ExInfo is on the stack and the thread is in COOP mode), this appears safe. But the coupling between a debugger hash table expecting real GC handles and a novel stack-address pseudo-handle is subtle.

Suggestion: Consider adding a brief comment at the ShouldSendCatchHandlerFound usage noting that the pseudo-handle is safe to pass to Lookup because ObjectFromHandle only dereferences the pointer (identical to dereferencing a real handle).

⚠️ DAC Pseudo-Handle Lifetime — GetCurrentException returns stack address to right side (advisory, not merge-blocking)

In dacdbiimpl.cpp:4958-4970, GetCurrentException returns the pseudo-handle (address of ExInfo::m_exception on the stack) as a VMPTR_OBJECTHANDLE to the debugger right side. Previously this was a GC handle table entry (long-lived heap memory).

Since DAC operates on point-in-time snapshots of a suspended process, this is safe for typical scenarios. However, if any right-side debugger code caches this handle value across process resume/suspend cycles, it could reference a dangling stack address (the ExInfo may have been popped). This is unlikely given the ICorDebug protocol re-queries state after resume, but warrants confirmation from someone familiar with the right-side implementation.

✅ Interop Changes — ObjC exception propagation correctly updated

The GetPropagatingExceptionCallback signature change from OBJECTHANDLE to OBJECTREF is correct. The caller in exceptionhandling.cpp:3957 passes pTopExInfo->m_exception directly. The callee properly GCPROTECT_BEGINs the OBJECTREF before making calls that could trigger GC. The mode contract correctly changed from MODE_PREEMPTIVE to MODE_COOPERATIVE.

✅ AsmOffsets — Field offset adjustments are consistent

Removing the 8-byte m_hThrowable field shifts subsequent field offsets down by 8 bytes on 64-bit (0xa8→0xa0, 0xb0→0xa8, etc.) and 4 bytes on 32-bit (0x5c→0x58, 0x60→0x5c, etc.). The AsmOffsets.cs changes reflect exactly this shift.

✅ cDAC Contract Updates — Correct adaptation

The cDAC changes correctly:

  • Change ExceptionInfo.ThrownObjectHandle (ObjectHandle) to ThrownObject (pointer)
  • Compute the pseudo-handle by adding the field offset to the ExInfo address
  • Short-circuit to null when ThrownObject == TargetPointer.Null
  • Tests are updated to verify read-through semantics (asserting the pointer dereferences to the expected object)

✅ Thread Safety — No races in the scanning path

The ExInfo chain is only modified on the owning thread (exception dispatch is per-thread), and GC scanning only occurs when threads are suspended. There is no race between writing m_exception and reading it during GC.

💡 Minor — prestub.cpp AppendElement calls

In prestub.cpp:1929 and 2134, the code now does StackTraceInfo::AppendElement(ObjectFromHandle(ohThrowable), ...). The handle here is LastThrownObjectHandle() (a real GC handle, not a pseudo-handle), so this is correct. Just noting the pattern is intentionally different from the ExInfo path.

Generated by Code Review for issue #127300 ·

The ExInfo::m_exception slot is GC-reported via the ExInfo chain scanner in ScanStackRoots (gcenv.ee.cpp). Reporting the same OBJECTREF location to the GC twice (once via GCPROTECT, once via the chain scanner) corrupts the relocate phase: the slot gets relocated twice, producing a garbage pointer that AVs on the next read.
This was missed in the earlier conversion of the three sites in exceptionhandling.cpp. It was reproducible on x86 checked builds running JitTest_throw_SEH, where SEH (hardware faults) routes through HandleManagedFault.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 04:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated no new comments.

@max-charlamb

Copy link
Copy Markdown
MemberAuthor

/ba-g Test failures are unrelated to PR changes

@max-charlamb
max-charlamb merged commit dc3d5a0 into mainMay 1, 2026
107 of 119 checks passed
@max-charlamb
max-charlamb deleted the dev/max-charlamb/exception-direct-pointer branch May 1, 2026 14:30
max-charlamb added a commit to max-charlamb/runtime that referenced this pull request May 1, 2026
Two changes responding to PR review feedback:
1. Remove dead CEEInfo::HandleException.
The function has been unreachable since 2016 (commit 4d9f4b8 `Remove SEH interactions between the JIT and the EE'') which replaced the old ICorJitInfo::FilterException/HandleException pair with runWithErrorTrap. The function is private, non-virtual, not part of the ICorJitInfo interface, and has zero callers in coreclr, the JIT, the AOT thunks, or SuperPMI (the SuperPMI Packet_HandleException slot is commented out). Removing it also retires the long-stale comment about `sync between the LTO and the exception tracker'' that pre-dates the ExInfo redesign and the lazy-LTO model from dotnet#127300/dotnet#127649.
2. Reorder declarations in threads.h so SetLastThrownObject precedes SetSOForLastThrownObject, matching the order of the definitions in threads.cpp.
Also update an unrelated stale comment in ExInfo::PopExInfos: the `unmanaged thread'' rationale is incorrect because both UMThunkUnwindFrameChainHandler and CallDescrWorkerUnwindFrameChainHandler short-circuit unmanaged threads before reaching PopExInfos, and the function carries a MODE_COOPERATIVE contract.
No behavior change.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hoyosjs added a commit that referenced this pull request May 5, 2026
…xception dispatch (#127741)
After #127300 removed ExInfo::m_hThrowable and SetThrowable(),
Thread::m_LastThrownObjectHandle is no longer updated during active
exception dispatch. This causes a staleclastThrownObjectHandle to ge
returned by GetThreadData when a debugger breaks into the target
mid-dispatch.
When debugging a .NET 11 process with SOS (e.g. via dotnet-dump or
WinDbg), the following commands fail if the debuggee is stopped during
exception dispatch (for example, with SXE CLR):
- !PrintException — reports "Invalid exception object" because it
dereferences the stale handle and gets a null or recycled pointer
- !Threads — shows no exception column for threads that are actively
throwing
- !clrstack -a with exception context — may show incomplete or missing
exception info
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 1, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@janvorli@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

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

Merged
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer
May 1, 2026
Merged

Remove ExInfo::m_hThrowable - use direct pointer for exception objects#127300
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer

Conversation

@max-charlamb

@max-charlambmax-charlamb commented Apr 22, 2026

Copy link
Copy Markdown
Member

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 section 2.1.5). Debug OBJECTREF tracking is satisfied via Thread::ObjectRefProtected in the ExInfo constructor.
  • Add ExInfo::GetThrowableAsPseudoHandle() - returns the target address of m_exception as a pseudo-handle (not a real GC handle table entry). Uses PTR_HOST_MEMBER_TADDR for correct DAC target address computation. The slot is updated during GC by the ExInfo chain scanner.
  • Remove GetThrowableAsHandle() - replaced by GetThrowableAsPseudoHandle() on ExInfo, ThreadExceptionState, and Thread
  • Remove SetThrowable() entirely - managed EH code writes m_exception directly; the SetThrowableErrorChecking enum and STEC_* constants are also removed.
  • Remove GetMyThread() - dead function with zero callers
  • Merge AppendElementImpl into AppendElement - only one caller remained after removing the OBJECTHANDLE overload. The merged function handles foreign exception semantics and preallocated exception checks.
  • Remove OBJECTHANDLE overload of AppendElement - all callers now pass OBJECTREF directly (including prestub.cpp and threads.cpp via ObjectFromHandle)
  • Fix StackTraceInfo::AppendElement preallocated-exception check: changed from IsPreallocatedExceptionHandle to IsPreallocatedExceptionObject(ObjectFromHandle(...))
  • 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: GetCurrentException reads from ExInfo first via GetThrowableAsPseudoHandle(), falls back to m_LastThrownObjectHandle. GetThreadException uses same pattern.
  • Update cDAC: ThrownObjectHandle -> ThrownObject (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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from db309be to e6688b3CompareApril 22, 2026 21:54
CopilotAI review requested due to automatic review settings April 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e6688b3 to 2812642CompareApril 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 2812642 to 9d54eecCompareApril 22, 2026 22:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/native/managed/cdac/tests/ThreadTests.cs
Comment threadsrc/native/managed/cdac/tests/ThreadTests.cs Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 9d54eec to e761d3eCompareApril 23, 2026 02:35
CopilotAI review requested due to automatic review settings April 23, 2026 03:01
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e761d3e to 34f7963CompareApril 23, 2026 03:01

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/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>
CopilotAI review requested due to automatic review settings April 24, 2026 01:38
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from adffa37 to 7aae554CompareApril 24, 2026 01:38

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/coreclr/vm/gcenv.ee.cpp
Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
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 marked this pull request as ready for review April 24, 2026 18:58

@janvorlijanvorli left a comment

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.

LGTM, thank you!

Comment threadsrc/coreclr/vm/excep.cpp Outdated
@janvorli

Copy link
Copy Markdown
Member

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

AppendElementImpl had only one caller after removing the OBJECTHANDLE
overload. Inline it into AppendElement and remove the separate function.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Comment threadsrc/coreclr/vm/threads.h Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 17:54
@github-actions

This comment has been minimized.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/debug/daccess/request.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

Verified on the private diagnostics suite

dac_cast<TADDR>(&m_exception) returns the host address in DAC builds,
but we need the target address. PTR_HOST_MEMBER_TADDR correctly
computes the target address of the field.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@max-charlamb
max-charlamb enabled auto-merge (squash) April 30, 2026 19:46
@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

🤖 Copilot Code Review — PR #127300

Holistic Assessment

Motivation: The PR removes the GC handle indirection (m_hThrowable) for exception objects in ExInfo, replacing it with a direct OBJECTREF that is GC-reported via ExInfo chain scanning in ScanStackRoots. This eliminates handle allocation/deallocation overhead, saves 8 bytes per ExInfo on 64-bit, and aligns CoreCLR with NativeAOT's exception handling pattern. The motivation is sound — handle-based exception storage was a legacy design.

Approach: The approach is well-reasoned. GC reporting is moved to an explicit scan of the ExInfo chain in ScanStackRoots, GCPROTECT is removed (to avoid double-reporting which corrupts GC relocation), and DAC/debugger consumers get a pseudo-handle (the target address of the m_exception field) that semantically behaves like a handle for read-through purposes. The cDAC and test updates are consistent with the native changes.

Summary: ⚠️ Needs Human Review. The core GC mechanism appears correct and the implementation is clean, but there are two subtle concerns around the debugger pseudo-handle contract that warrant human verification from someone familiar with the ICorDebug right-side and the ForceCatchHandlerFoundTable interaction.


Detailed Findings

✅ GC Correctness — ExInfo chain scanning is sound

The new scanning loop in gcenv.ee.cpp:209-220 correctly walks the ExInfo chain and reports each m_exception slot. Key verification:

  • m_exception is initialized to NULL in the ExInfo constructor member initializer list before the ExInfo is linked into the chain (line 40 of exinfo.cpp).
  • The GC's Promote function safely handles NULL object pointers (interface.cpp:1057is_in_find_object_range(NULL) returns false, causing early exit).
  • The constructor links the ExInfo into the chain (m_pCurrentTracker = this) before any exception object is stored, so there's no window where a non-null m_exception exists in an unscanned ExInfo.
  • Removal of GCPROTECT_BEGIN/END is correct since double-reporting the same slot corrupts GC relocation logic.
  • USE_CHECKED_OBJECTREFS debug registration (Thread::ObjectRefProtected) ensures checked builds validate the root.
  • The comment correctly references NativeAOT's GcScanRootsWorker as the analogous pattern.

✅ Cleanup Completeness — No leftover references

Verified: no remaining references to m_hThrowable, SetThrowable, STEC_All, SetThrowableErrorChecking, AppendElementImpl, or GetThrowableAsHandle exist in the codebase. The removal is thorough.

⚠️ Debugger Integration — ForceCatchHandlerFoundTable lookup with pseudo-handle (advisory, not merge-blocking)

In debugger.cpp:7870-7871:

OBJECTHANDLE objHandle = pThread->GetThrowableAsPseudoHandle();
OBJECTHANDLE retrievedHandle = m_pForceCatchHandlerFoundEventsTable->Lookup(objHandle);

The ForceCatchHandlerFoundSHashTraits::Equals and Hash functions (debugger.h:503-513) call ObjectFromHandle on both the stored handles (real long weak handles created via CreateLongWeakHandle) and the lookup key (pseudo-handle). Since ObjectFromHandle just dereferences *(Object**)handle, both a real handle and the pseudo-handle correctly resolve to the same Object* when the same exception is active. This works correctly.

However, this creates an implicit contract: the pseudo-handle must be dereferenceable whenever ShouldSendCatchHandlerFound is called. Since this only happens during active exception dispatch (when the ExInfo is on the stack and the thread is in COOP mode), this appears safe. But the coupling between a debugger hash table expecting real GC handles and a novel stack-address pseudo-handle is subtle.

Suggestion: Consider adding a brief comment at the ShouldSendCatchHandlerFound usage noting that the pseudo-handle is safe to pass to Lookup because ObjectFromHandle only dereferences the pointer (identical to dereferencing a real handle).

⚠️ DAC Pseudo-Handle Lifetime — GetCurrentException returns stack address to right side (advisory, not merge-blocking)

In dacdbiimpl.cpp:4958-4970, GetCurrentException returns the pseudo-handle (address of ExInfo::m_exception on the stack) as a VMPTR_OBJECTHANDLE to the debugger right side. Previously this was a GC handle table entry (long-lived heap memory).

Since DAC operates on point-in-time snapshots of a suspended process, this is safe for typical scenarios. However, if any right-side debugger code caches this handle value across process resume/suspend cycles, it could reference a dangling stack address (the ExInfo may have been popped). This is unlikely given the ICorDebug protocol re-queries state after resume, but warrants confirmation from someone familiar with the right-side implementation.

✅ Interop Changes — ObjC exception propagation correctly updated

The GetPropagatingExceptionCallback signature change from OBJECTHANDLE to OBJECTREF is correct. The caller in exceptionhandling.cpp:3957 passes pTopExInfo->m_exception directly. The callee properly GCPROTECT_BEGINs the OBJECTREF before making calls that could trigger GC. The mode contract correctly changed from MODE_PREEMPTIVE to MODE_COOPERATIVE.

✅ AsmOffsets — Field offset adjustments are consistent

Removing the 8-byte m_hThrowable field shifts subsequent field offsets down by 8 bytes on 64-bit (0xa8→0xa0, 0xb0→0xa8, etc.) and 4 bytes on 32-bit (0x5c→0x58, 0x60→0x5c, etc.). The AsmOffsets.cs changes reflect exactly this shift.

✅ cDAC Contract Updates — Correct adaptation

The cDAC changes correctly:

  • Change ExceptionInfo.ThrownObjectHandle (ObjectHandle) to ThrownObject (pointer)
  • Compute the pseudo-handle by adding the field offset to the ExInfo address
  • Short-circuit to null when ThrownObject == TargetPointer.Null
  • Tests are updated to verify read-through semantics (asserting the pointer dereferences to the expected object)

✅ Thread Safety — No races in the scanning path

The ExInfo chain is only modified on the owning thread (exception dispatch is per-thread), and GC scanning only occurs when threads are suspended. There is no race between writing m_exception and reading it during GC.

💡 Minor — prestub.cpp AppendElement calls

In prestub.cpp:1929 and 2134, the code now does StackTraceInfo::AppendElement(ObjectFromHandle(ohThrowable), ...). The handle here is LastThrownObjectHandle() (a real GC handle, not a pseudo-handle), so this is correct. Just noting the pattern is intentionally different from the ExInfo path.

Generated by Code Review for issue #127300 ·

The ExInfo::m_exception slot is GC-reported via the ExInfo chain scanner in ScanStackRoots (gcenv.ee.cpp). Reporting the same OBJECTREF location to the GC twice (once via GCPROTECT, once via the chain scanner) corrupts the relocate phase: the slot gets relocated twice, producing a garbage pointer that AVs on the next read.
This was missed in the earlier conversion of the three sites in exceptionhandling.cpp. It was reproducible on x86 checked builds running JitTest_throw_SEH, where SEH (hardware faults) routes through HandleManagedFault.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 04:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated no new comments.

@max-charlamb

Copy link
Copy Markdown
MemberAuthor

/ba-g Test failures are unrelated to PR changes

@max-charlamb
max-charlamb merged commit dc3d5a0 into mainMay 1, 2026
107 of 119 checks passed
@max-charlamb
max-charlamb deleted the dev/max-charlamb/exception-direct-pointer branch May 1, 2026 14:30
max-charlamb added a commit to max-charlamb/runtime that referenced this pull request May 1, 2026
Two changes responding to PR review feedback:
1. Remove dead CEEInfo::HandleException.
The function has been unreachable since 2016 (commit 4d9f4b8 `Remove SEH interactions between the JIT and the EE'') which replaced the old ICorJitInfo::FilterException/HandleException pair with runWithErrorTrap. The function is private, non-virtual, not part of the ICorJitInfo interface, and has zero callers in coreclr, the JIT, the AOT thunks, or SuperPMI (the SuperPMI Packet_HandleException slot is commented out). Removing it also retires the long-stale comment about `sync between the LTO and the exception tracker'' that pre-dates the ExInfo redesign and the lazy-LTO model from dotnet#127300/dotnet#127649.
2. Reorder declarations in threads.h so SetLastThrownObject precedes SetSOForLastThrownObject, matching the order of the definitions in threads.cpp.
Also update an unrelated stale comment in ExInfo::PopExInfos: the `unmanaged thread'' rationale is incorrect because both UMThunkUnwindFrameChainHandler and CallDescrWorkerUnwindFrameChainHandler short-circuit unmanaged threads before reaching PopExInfos, and the function carries a MODE_COOPERATIVE contract.
No behavior change.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hoyosjs added a commit that referenced this pull request May 5, 2026
…xception dispatch (#127741)
After #127300 removed ExInfo::m_hThrowable and SetThrowable(),
Thread::m_LastThrownObjectHandle is no longer updated during active
exception dispatch. This causes a staleclastThrownObjectHandle to ge
returned by GetThreadData when a debugger breaks into the target
mid-dispatch.
When debugging a .NET 11 process with SOS (e.g. via dotnet-dump or
WinDbg), the following commands fail if the debuggee is stopped during
exception dispatch (for example, with SXE CLR):
- !PrintException — reports "Invalid exception object" because it
dereferences the stale handle and gets a null or recycled pointer
- !Threads — shows no exception column for threads that are actively
throwing
- !clrstack -a with exception context — may show incomplete or missing
exception info
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 1, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@janvorli@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer
May 1, 2026
Merged

Remove ExInfo::m_hThrowable - use direct pointer for exception objects#127300
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer

Conversation

@max-charlamb

@max-charlambmax-charlamb commented Apr 22, 2026

Copy link
Copy Markdown
Member

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 section 2.1.5). Debug OBJECTREF tracking is satisfied via Thread::ObjectRefProtected in the ExInfo constructor.
  • Add ExInfo::GetThrowableAsPseudoHandle() - returns the target address of m_exception as a pseudo-handle (not a real GC handle table entry). Uses PTR_HOST_MEMBER_TADDR for correct DAC target address computation. The slot is updated during GC by the ExInfo chain scanner.
  • Remove GetThrowableAsHandle() - replaced by GetThrowableAsPseudoHandle() on ExInfo, ThreadExceptionState, and Thread
  • Remove SetThrowable() entirely - managed EH code writes m_exception directly; the SetThrowableErrorChecking enum and STEC_* constants are also removed.
  • Remove GetMyThread() - dead function with zero callers
  • Merge AppendElementImpl into AppendElement - only one caller remained after removing the OBJECTHANDLE overload. The merged function handles foreign exception semantics and preallocated exception checks.
  • Remove OBJECTHANDLE overload of AppendElement - all callers now pass OBJECTREF directly (including prestub.cpp and threads.cpp via ObjectFromHandle)
  • Fix StackTraceInfo::AppendElement preallocated-exception check: changed from IsPreallocatedExceptionHandle to IsPreallocatedExceptionObject(ObjectFromHandle(...))
  • 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: GetCurrentException reads from ExInfo first via GetThrowableAsPseudoHandle(), falls back to m_LastThrownObjectHandle. GetThreadException uses same pattern.
  • Update cDAC: ThrownObjectHandle -> ThrownObject (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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from db309be to e6688b3CompareApril 22, 2026 21:54
CopilotAI review requested due to automatic review settings April 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e6688b3 to 2812642CompareApril 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 2812642 to 9d54eecCompareApril 22, 2026 22:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/native/managed/cdac/tests/ThreadTests.cs
Comment threadsrc/native/managed/cdac/tests/ThreadTests.cs Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 9d54eec to e761d3eCompareApril 23, 2026 02:35
CopilotAI review requested due to automatic review settings April 23, 2026 03:01
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e761d3e to 34f7963CompareApril 23, 2026 03:01

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/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>
CopilotAI review requested due to automatic review settings April 24, 2026 01:38
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from adffa37 to 7aae554CompareApril 24, 2026 01:38

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/coreclr/vm/gcenv.ee.cpp
Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
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 marked this pull request as ready for review April 24, 2026 18:58

@janvorlijanvorli left a comment

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.

LGTM, thank you!

Comment threadsrc/coreclr/vm/excep.cpp Outdated
@janvorli

Copy link
Copy Markdown
Member

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

AppendElementImpl had only one caller after removing the OBJECTHANDLE
overload. Inline it into AppendElement and remove the separate function.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Comment threadsrc/coreclr/vm/threads.h Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 17:54
@github-actions

This comment has been minimized.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/debug/daccess/request.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

Verified on the private diagnostics suite

dac_cast<TADDR>(&m_exception) returns the host address in DAC builds,
but we need the target address. PTR_HOST_MEMBER_TADDR correctly
computes the target address of the field.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@max-charlamb
max-charlamb enabled auto-merge (squash) April 30, 2026 19:46
@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

🤖 Copilot Code Review — PR #127300

Holistic Assessment

Motivation: The PR removes the GC handle indirection (m_hThrowable) for exception objects in ExInfo, replacing it with a direct OBJECTREF that is GC-reported via ExInfo chain scanning in ScanStackRoots. This eliminates handle allocation/deallocation overhead, saves 8 bytes per ExInfo on 64-bit, and aligns CoreCLR with NativeAOT's exception handling pattern. The motivation is sound — handle-based exception storage was a legacy design.

Approach: The approach is well-reasoned. GC reporting is moved to an explicit scan of the ExInfo chain in ScanStackRoots, GCPROTECT is removed (to avoid double-reporting which corrupts GC relocation), and DAC/debugger consumers get a pseudo-handle (the target address of the m_exception field) that semantically behaves like a handle for read-through purposes. The cDAC and test updates are consistent with the native changes.

Summary: ⚠️ Needs Human Review. The core GC mechanism appears correct and the implementation is clean, but there are two subtle concerns around the debugger pseudo-handle contract that warrant human verification from someone familiar with the ICorDebug right-side and the ForceCatchHandlerFoundTable interaction.


Detailed Findings

✅ GC Correctness — ExInfo chain scanning is sound

The new scanning loop in gcenv.ee.cpp:209-220 correctly walks the ExInfo chain and reports each m_exception slot. Key verification:

  • m_exception is initialized to NULL in the ExInfo constructor member initializer list before the ExInfo is linked into the chain (line 40 of exinfo.cpp).
  • The GC's Promote function safely handles NULL object pointers (interface.cpp:1057is_in_find_object_range(NULL) returns false, causing early exit).
  • The constructor links the ExInfo into the chain (m_pCurrentTracker = this) before any exception object is stored, so there's no window where a non-null m_exception exists in an unscanned ExInfo.
  • Removal of GCPROTECT_BEGIN/END is correct since double-reporting the same slot corrupts GC relocation logic.
  • USE_CHECKED_OBJECTREFS debug registration (Thread::ObjectRefProtected) ensures checked builds validate the root.
  • The comment correctly references NativeAOT's GcScanRootsWorker as the analogous pattern.

✅ Cleanup Completeness — No leftover references

Verified: no remaining references to m_hThrowable, SetThrowable, STEC_All, SetThrowableErrorChecking, AppendElementImpl, or GetThrowableAsHandle exist in the codebase. The removal is thorough.

⚠️ Debugger Integration — ForceCatchHandlerFoundTable lookup with pseudo-handle (advisory, not merge-blocking)

In debugger.cpp:7870-7871:

OBJECTHANDLE objHandle = pThread->GetThrowableAsPseudoHandle();
OBJECTHANDLE retrievedHandle = m_pForceCatchHandlerFoundEventsTable->Lookup(objHandle);

The ForceCatchHandlerFoundSHashTraits::Equals and Hash functions (debugger.h:503-513) call ObjectFromHandle on both the stored handles (real long weak handles created via CreateLongWeakHandle) and the lookup key (pseudo-handle). Since ObjectFromHandle just dereferences *(Object**)handle, both a real handle and the pseudo-handle correctly resolve to the same Object* when the same exception is active. This works correctly.

However, this creates an implicit contract: the pseudo-handle must be dereferenceable whenever ShouldSendCatchHandlerFound is called. Since this only happens during active exception dispatch (when the ExInfo is on the stack and the thread is in COOP mode), this appears safe. But the coupling between a debugger hash table expecting real GC handles and a novel stack-address pseudo-handle is subtle.

Suggestion: Consider adding a brief comment at the ShouldSendCatchHandlerFound usage noting that the pseudo-handle is safe to pass to Lookup because ObjectFromHandle only dereferences the pointer (identical to dereferencing a real handle).

⚠️ DAC Pseudo-Handle Lifetime — GetCurrentException returns stack address to right side (advisory, not merge-blocking)

In dacdbiimpl.cpp:4958-4970, GetCurrentException returns the pseudo-handle (address of ExInfo::m_exception on the stack) as a VMPTR_OBJECTHANDLE to the debugger right side. Previously this was a GC handle table entry (long-lived heap memory).

Since DAC operates on point-in-time snapshots of a suspended process, this is safe for typical scenarios. However, if any right-side debugger code caches this handle value across process resume/suspend cycles, it could reference a dangling stack address (the ExInfo may have been popped). This is unlikely given the ICorDebug protocol re-queries state after resume, but warrants confirmation from someone familiar with the right-side implementation.

✅ Interop Changes — ObjC exception propagation correctly updated

The GetPropagatingExceptionCallback signature change from OBJECTHANDLE to OBJECTREF is correct. The caller in exceptionhandling.cpp:3957 passes pTopExInfo->m_exception directly. The callee properly GCPROTECT_BEGINs the OBJECTREF before making calls that could trigger GC. The mode contract correctly changed from MODE_PREEMPTIVE to MODE_COOPERATIVE.

✅ AsmOffsets — Field offset adjustments are consistent

Removing the 8-byte m_hThrowable field shifts subsequent field offsets down by 8 bytes on 64-bit (0xa8→0xa0, 0xb0→0xa8, etc.) and 4 bytes on 32-bit (0x5c→0x58, 0x60→0x5c, etc.). The AsmOffsets.cs changes reflect exactly this shift.

✅ cDAC Contract Updates — Correct adaptation

The cDAC changes correctly:

  • Change ExceptionInfo.ThrownObjectHandle (ObjectHandle) to ThrownObject (pointer)
  • Compute the pseudo-handle by adding the field offset to the ExInfo address
  • Short-circuit to null when ThrownObject == TargetPointer.Null
  • Tests are updated to verify read-through semantics (asserting the pointer dereferences to the expected object)

✅ Thread Safety — No races in the scanning path

The ExInfo chain is only modified on the owning thread (exception dispatch is per-thread), and GC scanning only occurs when threads are suspended. There is no race between writing m_exception and reading it during GC.

💡 Minor — prestub.cpp AppendElement calls

In prestub.cpp:1929 and 2134, the code now does StackTraceInfo::AppendElement(ObjectFromHandle(ohThrowable), ...). The handle here is LastThrownObjectHandle() (a real GC handle, not a pseudo-handle), so this is correct. Just noting the pattern is intentionally different from the ExInfo path.

Generated by Code Review for issue #127300 ·

The ExInfo::m_exception slot is GC-reported via the ExInfo chain scanner in ScanStackRoots (gcenv.ee.cpp). Reporting the same OBJECTREF location to the GC twice (once via GCPROTECT, once via the chain scanner) corrupts the relocate phase: the slot gets relocated twice, producing a garbage pointer that AVs on the next read.
This was missed in the earlier conversion of the three sites in exceptionhandling.cpp. It was reproducible on x86 checked builds running JitTest_throw_SEH, where SEH (hardware faults) routes through HandleManagedFault.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 04:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated no new comments.

@max-charlamb

Copy link
Copy Markdown
MemberAuthor

/ba-g Test failures are unrelated to PR changes

@max-charlamb
max-charlamb merged commit dc3d5a0 into mainMay 1, 2026
107 of 119 checks passed
@max-charlamb
max-charlamb deleted the dev/max-charlamb/exception-direct-pointer branch May 1, 2026 14:30
max-charlamb added a commit to max-charlamb/runtime that referenced this pull request May 1, 2026
Two changes responding to PR review feedback:
1. Remove dead CEEInfo::HandleException.
The function has been unreachable since 2016 (commit 4d9f4b8 `Remove SEH interactions between the JIT and the EE'') which replaced the old ICorJitInfo::FilterException/HandleException pair with runWithErrorTrap. The function is private, non-virtual, not part of the ICorJitInfo interface, and has zero callers in coreclr, the JIT, the AOT thunks, or SuperPMI (the SuperPMI Packet_HandleException slot is commented out). Removing it also retires the long-stale comment about `sync between the LTO and the exception tracker'' that pre-dates the ExInfo redesign and the lazy-LTO model from dotnet#127300/dotnet#127649.
2. Reorder declarations in threads.h so SetLastThrownObject precedes SetSOForLastThrownObject, matching the order of the definitions in threads.cpp.
Also update an unrelated stale comment in ExInfo::PopExInfos: the `unmanaged thread'' rationale is incorrect because both UMThunkUnwindFrameChainHandler and CallDescrWorkerUnwindFrameChainHandler short-circuit unmanaged threads before reaching PopExInfos, and the function carries a MODE_COOPERATIVE contract.
No behavior change.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hoyosjs added a commit that referenced this pull request May 5, 2026
…xception dispatch (#127741)
After #127300 removed ExInfo::m_hThrowable and SetThrowable(),
Thread::m_LastThrownObjectHandle is no longer updated during active
exception dispatch. This causes a staleclastThrownObjectHandle to ge
returned by GetThreadData when a debugger breaks into the target
mid-dispatch.
When debugging a .NET 11 process with SOS (e.g. via dotnet-dump or
WinDbg), the following commands fail if the debuggee is stopped during
exception dispatch (for example, with SXE CLR):
- !PrintException — reports "Invalid exception object" because it
dereferences the stale handle and gets a null or recycled pointer
- !Threads — shows no exception column for threads that are actively
throwing
- !clrstack -a with exception context — may show incomplete or missing
exception info
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 1, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@janvorli@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

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

Merged
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer
May 1, 2026
Merged

Remove ExInfo::m_hThrowable - use direct pointer for exception objects#127300
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer

Conversation

@max-charlamb

@max-charlambmax-charlamb commented Apr 22, 2026

Copy link
Copy Markdown
Member

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 section 2.1.5). Debug OBJECTREF tracking is satisfied via Thread::ObjectRefProtected in the ExInfo constructor.
  • Add ExInfo::GetThrowableAsPseudoHandle() - returns the target address of m_exception as a pseudo-handle (not a real GC handle table entry). Uses PTR_HOST_MEMBER_TADDR for correct DAC target address computation. The slot is updated during GC by the ExInfo chain scanner.
  • Remove GetThrowableAsHandle() - replaced by GetThrowableAsPseudoHandle() on ExInfo, ThreadExceptionState, and Thread
  • Remove SetThrowable() entirely - managed EH code writes m_exception directly; the SetThrowableErrorChecking enum and STEC_* constants are also removed.
  • Remove GetMyThread() - dead function with zero callers
  • Merge AppendElementImpl into AppendElement - only one caller remained after removing the OBJECTHANDLE overload. The merged function handles foreign exception semantics and preallocated exception checks.
  • Remove OBJECTHANDLE overload of AppendElement - all callers now pass OBJECTREF directly (including prestub.cpp and threads.cpp via ObjectFromHandle)
  • Fix StackTraceInfo::AppendElement preallocated-exception check: changed from IsPreallocatedExceptionHandle to IsPreallocatedExceptionObject(ObjectFromHandle(...))
  • 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: GetCurrentException reads from ExInfo first via GetThrowableAsPseudoHandle(), falls back to m_LastThrownObjectHandle. GetThreadException uses same pattern.
  • Update cDAC: ThrownObjectHandle -> ThrownObject (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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from db309be to e6688b3CompareApril 22, 2026 21:54
CopilotAI review requested due to automatic review settings April 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e6688b3 to 2812642CompareApril 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 2812642 to 9d54eecCompareApril 22, 2026 22:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/native/managed/cdac/tests/ThreadTests.cs
Comment threadsrc/native/managed/cdac/tests/ThreadTests.cs Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 9d54eec to e761d3eCompareApril 23, 2026 02:35
CopilotAI review requested due to automatic review settings April 23, 2026 03:01
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e761d3e to 34f7963CompareApril 23, 2026 03:01

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/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>
CopilotAI review requested due to automatic review settings April 24, 2026 01:38
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from adffa37 to 7aae554CompareApril 24, 2026 01:38

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/coreclr/vm/gcenv.ee.cpp
Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
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 marked this pull request as ready for review April 24, 2026 18:58

@janvorlijanvorli left a comment

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.

LGTM, thank you!

Comment threadsrc/coreclr/vm/excep.cpp Outdated
@janvorli

Copy link
Copy Markdown
Member

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

AppendElementImpl had only one caller after removing the OBJECTHANDLE
overload. Inline it into AppendElement and remove the separate function.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Comment threadsrc/coreclr/vm/threads.h Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 17:54
@github-actions

This comment has been minimized.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/debug/daccess/request.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

Verified on the private diagnostics suite

dac_cast<TADDR>(&m_exception) returns the host address in DAC builds,
but we need the target address. PTR_HOST_MEMBER_TADDR correctly
computes the target address of the field.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@max-charlamb
max-charlamb enabled auto-merge (squash) April 30, 2026 19:46
@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

🤖 Copilot Code Review — PR #127300

Holistic Assessment

Motivation: The PR removes the GC handle indirection (m_hThrowable) for exception objects in ExInfo, replacing it with a direct OBJECTREF that is GC-reported via ExInfo chain scanning in ScanStackRoots. This eliminates handle allocation/deallocation overhead, saves 8 bytes per ExInfo on 64-bit, and aligns CoreCLR with NativeAOT's exception handling pattern. The motivation is sound — handle-based exception storage was a legacy design.

Approach: The approach is well-reasoned. GC reporting is moved to an explicit scan of the ExInfo chain in ScanStackRoots, GCPROTECT is removed (to avoid double-reporting which corrupts GC relocation), and DAC/debugger consumers get a pseudo-handle (the target address of the m_exception field) that semantically behaves like a handle for read-through purposes. The cDAC and test updates are consistent with the native changes.

Summary: ⚠️ Needs Human Review. The core GC mechanism appears correct and the implementation is clean, but there are two subtle concerns around the debugger pseudo-handle contract that warrant human verification from someone familiar with the ICorDebug right-side and the ForceCatchHandlerFoundTable interaction.


Detailed Findings

✅ GC Correctness — ExInfo chain scanning is sound

The new scanning loop in gcenv.ee.cpp:209-220 correctly walks the ExInfo chain and reports each m_exception slot. Key verification:

  • m_exception is initialized to NULL in the ExInfo constructor member initializer list before the ExInfo is linked into the chain (line 40 of exinfo.cpp).
  • The GC's Promote function safely handles NULL object pointers (interface.cpp:1057is_in_find_object_range(NULL) returns false, causing early exit).
  • The constructor links the ExInfo into the chain (m_pCurrentTracker = this) before any exception object is stored, so there's no window where a non-null m_exception exists in an unscanned ExInfo.
  • Removal of GCPROTECT_BEGIN/END is correct since double-reporting the same slot corrupts GC relocation logic.
  • USE_CHECKED_OBJECTREFS debug registration (Thread::ObjectRefProtected) ensures checked builds validate the root.
  • The comment correctly references NativeAOT's GcScanRootsWorker as the analogous pattern.

✅ Cleanup Completeness — No leftover references

Verified: no remaining references to m_hThrowable, SetThrowable, STEC_All, SetThrowableErrorChecking, AppendElementImpl, or GetThrowableAsHandle exist in the codebase. The removal is thorough.

⚠️ Debugger Integration — ForceCatchHandlerFoundTable lookup with pseudo-handle (advisory, not merge-blocking)

In debugger.cpp:7870-7871:

OBJECTHANDLE objHandle = pThread->GetThrowableAsPseudoHandle();
OBJECTHANDLE retrievedHandle = m_pForceCatchHandlerFoundEventsTable->Lookup(objHandle);

The ForceCatchHandlerFoundSHashTraits::Equals and Hash functions (debugger.h:503-513) call ObjectFromHandle on both the stored handles (real long weak handles created via CreateLongWeakHandle) and the lookup key (pseudo-handle). Since ObjectFromHandle just dereferences *(Object**)handle, both a real handle and the pseudo-handle correctly resolve to the same Object* when the same exception is active. This works correctly.

However, this creates an implicit contract: the pseudo-handle must be dereferenceable whenever ShouldSendCatchHandlerFound is called. Since this only happens during active exception dispatch (when the ExInfo is on the stack and the thread is in COOP mode), this appears safe. But the coupling between a debugger hash table expecting real GC handles and a novel stack-address pseudo-handle is subtle.

Suggestion: Consider adding a brief comment at the ShouldSendCatchHandlerFound usage noting that the pseudo-handle is safe to pass to Lookup because ObjectFromHandle only dereferences the pointer (identical to dereferencing a real handle).

⚠️ DAC Pseudo-Handle Lifetime — GetCurrentException returns stack address to right side (advisory, not merge-blocking)

In dacdbiimpl.cpp:4958-4970, GetCurrentException returns the pseudo-handle (address of ExInfo::m_exception on the stack) as a VMPTR_OBJECTHANDLE to the debugger right side. Previously this was a GC handle table entry (long-lived heap memory).

Since DAC operates on point-in-time snapshots of a suspended process, this is safe for typical scenarios. However, if any right-side debugger code caches this handle value across process resume/suspend cycles, it could reference a dangling stack address (the ExInfo may have been popped). This is unlikely given the ICorDebug protocol re-queries state after resume, but warrants confirmation from someone familiar with the right-side implementation.

✅ Interop Changes — ObjC exception propagation correctly updated

The GetPropagatingExceptionCallback signature change from OBJECTHANDLE to OBJECTREF is correct. The caller in exceptionhandling.cpp:3957 passes pTopExInfo->m_exception directly. The callee properly GCPROTECT_BEGINs the OBJECTREF before making calls that could trigger GC. The mode contract correctly changed from MODE_PREEMPTIVE to MODE_COOPERATIVE.

✅ AsmOffsets — Field offset adjustments are consistent

Removing the 8-byte m_hThrowable field shifts subsequent field offsets down by 8 bytes on 64-bit (0xa8→0xa0, 0xb0→0xa8, etc.) and 4 bytes on 32-bit (0x5c→0x58, 0x60→0x5c, etc.). The AsmOffsets.cs changes reflect exactly this shift.

✅ cDAC Contract Updates — Correct adaptation

The cDAC changes correctly:

  • Change ExceptionInfo.ThrownObjectHandle (ObjectHandle) to ThrownObject (pointer)
  • Compute the pseudo-handle by adding the field offset to the ExInfo address
  • Short-circuit to null when ThrownObject == TargetPointer.Null
  • Tests are updated to verify read-through semantics (asserting the pointer dereferences to the expected object)

✅ Thread Safety — No races in the scanning path

The ExInfo chain is only modified on the owning thread (exception dispatch is per-thread), and GC scanning only occurs when threads are suspended. There is no race between writing m_exception and reading it during GC.

💡 Minor — prestub.cpp AppendElement calls

In prestub.cpp:1929 and 2134, the code now does StackTraceInfo::AppendElement(ObjectFromHandle(ohThrowable), ...). The handle here is LastThrownObjectHandle() (a real GC handle, not a pseudo-handle), so this is correct. Just noting the pattern is intentionally different from the ExInfo path.

Generated by Code Review for issue #127300 ·

The ExInfo::m_exception slot is GC-reported via the ExInfo chain scanner in ScanStackRoots (gcenv.ee.cpp). Reporting the same OBJECTREF location to the GC twice (once via GCPROTECT, once via the chain scanner) corrupts the relocate phase: the slot gets relocated twice, producing a garbage pointer that AVs on the next read.
This was missed in the earlier conversion of the three sites in exceptionhandling.cpp. It was reproducible on x86 checked builds running JitTest_throw_SEH, where SEH (hardware faults) routes through HandleManagedFault.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 04:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated no new comments.

@max-charlamb

Copy link
Copy Markdown
MemberAuthor

/ba-g Test failures are unrelated to PR changes

@max-charlamb
max-charlamb merged commit dc3d5a0 into mainMay 1, 2026
107 of 119 checks passed
@max-charlamb
max-charlamb deleted the dev/max-charlamb/exception-direct-pointer branch May 1, 2026 14:30
max-charlamb added a commit to max-charlamb/runtime that referenced this pull request May 1, 2026
Two changes responding to PR review feedback:
1. Remove dead CEEInfo::HandleException.
The function has been unreachable since 2016 (commit 4d9f4b8 `Remove SEH interactions between the JIT and the EE'') which replaced the old ICorJitInfo::FilterException/HandleException pair with runWithErrorTrap. The function is private, non-virtual, not part of the ICorJitInfo interface, and has zero callers in coreclr, the JIT, the AOT thunks, or SuperPMI (the SuperPMI Packet_HandleException slot is commented out). Removing it also retires the long-stale comment about `sync between the LTO and the exception tracker'' that pre-dates the ExInfo redesign and the lazy-LTO model from dotnet#127300/dotnet#127649.
2. Reorder declarations in threads.h so SetLastThrownObject precedes SetSOForLastThrownObject, matching the order of the definitions in threads.cpp.
Also update an unrelated stale comment in ExInfo::PopExInfos: the `unmanaged thread'' rationale is incorrect because both UMThunkUnwindFrameChainHandler and CallDescrWorkerUnwindFrameChainHandler short-circuit unmanaged threads before reaching PopExInfos, and the function carries a MODE_COOPERATIVE contract.
No behavior change.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hoyosjs added a commit that referenced this pull request May 5, 2026
…xception dispatch (#127741)
After #127300 removed ExInfo::m_hThrowable and SetThrowable(),
Thread::m_LastThrownObjectHandle is no longer updated during active
exception dispatch. This causes a staleclastThrownObjectHandle to ge
returned by GetThreadData when a debugger breaks into the target
mid-dispatch.
When debugging a .NET 11 process with SOS (e.g. via dotnet-dump or
WinDbg), the following commands fail if the debuggee is stopped during
exception dispatch (for example, with SXE CLR):
- !PrintException — reports "Invalid exception object" because it
dereferences the stale handle and gets a null or recycled pointer
- !Threads — shows no exception column for threads that are actively
throwing
- !clrstack -a with exception context — may show incomplete or missing
exception info
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 1, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@janvorli@jkotas
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

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

Merged
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer
May 1, 2026
Merged

Remove ExInfo::m_hThrowable - use direct pointer for exception objects#127300
max-charlamb merged 15 commits into
mainfrom
dev/max-charlamb/exception-direct-pointer

Conversation

@max-charlamb

@max-charlambmax-charlamb commented Apr 22, 2026

Copy link
Copy Markdown
Member

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 section 2.1.5). Debug OBJECTREF tracking is satisfied via Thread::ObjectRefProtected in the ExInfo constructor.
  • Add ExInfo::GetThrowableAsPseudoHandle() - returns the target address of m_exception as a pseudo-handle (not a real GC handle table entry). Uses PTR_HOST_MEMBER_TADDR for correct DAC target address computation. The slot is updated during GC by the ExInfo chain scanner.
  • Remove GetThrowableAsHandle() - replaced by GetThrowableAsPseudoHandle() on ExInfo, ThreadExceptionState, and Thread
  • Remove SetThrowable() entirely - managed EH code writes m_exception directly; the SetThrowableErrorChecking enum and STEC_* constants are also removed.
  • Remove GetMyThread() - dead function with zero callers
  • Merge AppendElementImpl into AppendElement - only one caller remained after removing the OBJECTHANDLE overload. The merged function handles foreign exception semantics and preallocated exception checks.
  • Remove OBJECTHANDLE overload of AppendElement - all callers now pass OBJECTREF directly (including prestub.cpp and threads.cpp via ObjectFromHandle)
  • Fix StackTraceInfo::AppendElement preallocated-exception check: changed from IsPreallocatedExceptionHandle to IsPreallocatedExceptionObject(ObjectFromHandle(...))
  • 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: GetCurrentException reads from ExInfo first via GetThrowableAsPseudoHandle(), falls back to m_LastThrownObjectHandle. GetThreadException uses same pattern.
  • Update cDAC: ThrownObjectHandle -> ThrownObject (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

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from db309be to e6688b3CompareApril 22, 2026 21:54
CopilotAI review requested due to automatic review settings April 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e6688b3 to 2812642CompareApril 22, 2026 22:12
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 2812642 to 9d54eecCompareApril 22, 2026 22:15

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/native/managed/cdac/tests/ThreadTests.cs
Comment threadsrc/native/managed/cdac/tests/ThreadTests.cs Outdated
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from 9d54eec to e761d3eCompareApril 23, 2026 02:35
CopilotAI review requested due to automatic review settings April 23, 2026 03:01
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from e761d3e to 34f7963CompareApril 23, 2026 03:01

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/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>
CopilotAI review requested due to automatic review settings April 24, 2026 01:38
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/exception-direct-pointer branch from adffa37 to 7aae554CompareApril 24, 2026 01:38

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 threadsrc/coreclr/vm/gcenv.ee.cpp
Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
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 marked this pull request as ready for review April 24, 2026 18:58

@janvorlijanvorli left a comment

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.

LGTM, thank you!

Comment threadsrc/coreclr/vm/excep.cpp Outdated
@janvorli

Copy link
Copy Markdown
Member

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

AppendElementImpl had only one caller after removing the OBJECTHANDLE
overload. Inline it into AppendElement and remove the separate function.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

This comment has been minimized.

Comment threadsrc/coreclr/vm/threads.h Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 17:54
@github-actions

This comment has been minimized.

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/debug/daccess/request.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@max-charlamb, unless you already did, can you also please run the private diagnostic tests before merging this PR?

Verified on the private diagnostics suite

dac_cast<TADDR>(&m_exception) returns the host address in DAC builds,
but we need the target address. PTR_HOST_MEMBER_TADDR correctly
computes the target address of the field.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@max-charlamb
max-charlamb enabled auto-merge (squash) April 30, 2026 19:46
@github-actions

Copy link
Copy Markdown
Contributor

Note

This review was generated by Copilot.

🤖 Copilot Code Review — PR #127300

Holistic Assessment

Motivation: The PR removes the GC handle indirection (m_hThrowable) for exception objects in ExInfo, replacing it with a direct OBJECTREF that is GC-reported via ExInfo chain scanning in ScanStackRoots. This eliminates handle allocation/deallocation overhead, saves 8 bytes per ExInfo on 64-bit, and aligns CoreCLR with NativeAOT's exception handling pattern. The motivation is sound — handle-based exception storage was a legacy design.

Approach: The approach is well-reasoned. GC reporting is moved to an explicit scan of the ExInfo chain in ScanStackRoots, GCPROTECT is removed (to avoid double-reporting which corrupts GC relocation), and DAC/debugger consumers get a pseudo-handle (the target address of the m_exception field) that semantically behaves like a handle for read-through purposes. The cDAC and test updates are consistent with the native changes.

Summary: ⚠️ Needs Human Review. The core GC mechanism appears correct and the implementation is clean, but there are two subtle concerns around the debugger pseudo-handle contract that warrant human verification from someone familiar with the ICorDebug right-side and the ForceCatchHandlerFoundTable interaction.


Detailed Findings

✅ GC Correctness — ExInfo chain scanning is sound

The new scanning loop in gcenv.ee.cpp:209-220 correctly walks the ExInfo chain and reports each m_exception slot. Key verification:

  • m_exception is initialized to NULL in the ExInfo constructor member initializer list before the ExInfo is linked into the chain (line 40 of exinfo.cpp).
  • The GC's Promote function safely handles NULL object pointers (interface.cpp:1057is_in_find_object_range(NULL) returns false, causing early exit).
  • The constructor links the ExInfo into the chain (m_pCurrentTracker = this) before any exception object is stored, so there's no window where a non-null m_exception exists in an unscanned ExInfo.
  • Removal of GCPROTECT_BEGIN/END is correct since double-reporting the same slot corrupts GC relocation logic.
  • USE_CHECKED_OBJECTREFS debug registration (Thread::ObjectRefProtected) ensures checked builds validate the root.
  • The comment correctly references NativeAOT's GcScanRootsWorker as the analogous pattern.

✅ Cleanup Completeness — No leftover references

Verified: no remaining references to m_hThrowable, SetThrowable, STEC_All, SetThrowableErrorChecking, AppendElementImpl, or GetThrowableAsHandle exist in the codebase. The removal is thorough.

⚠️ Debugger Integration — ForceCatchHandlerFoundTable lookup with pseudo-handle (advisory, not merge-blocking)

In debugger.cpp:7870-7871:

OBJECTHANDLE objHandle = pThread->GetThrowableAsPseudoHandle();
OBJECTHANDLE retrievedHandle = m_pForceCatchHandlerFoundEventsTable->Lookup(objHandle);

The ForceCatchHandlerFoundSHashTraits::Equals and Hash functions (debugger.h:503-513) call ObjectFromHandle on both the stored handles (real long weak handles created via CreateLongWeakHandle) and the lookup key (pseudo-handle). Since ObjectFromHandle just dereferences *(Object**)handle, both a real handle and the pseudo-handle correctly resolve to the same Object* when the same exception is active. This works correctly.

However, this creates an implicit contract: the pseudo-handle must be dereferenceable whenever ShouldSendCatchHandlerFound is called. Since this only happens during active exception dispatch (when the ExInfo is on the stack and the thread is in COOP mode), this appears safe. But the coupling between a debugger hash table expecting real GC handles and a novel stack-address pseudo-handle is subtle.

Suggestion: Consider adding a brief comment at the ShouldSendCatchHandlerFound usage noting that the pseudo-handle is safe to pass to Lookup because ObjectFromHandle only dereferences the pointer (identical to dereferencing a real handle).

⚠️ DAC Pseudo-Handle Lifetime — GetCurrentException returns stack address to right side (advisory, not merge-blocking)

In dacdbiimpl.cpp:4958-4970, GetCurrentException returns the pseudo-handle (address of ExInfo::m_exception on the stack) as a VMPTR_OBJECTHANDLE to the debugger right side. Previously this was a GC handle table entry (long-lived heap memory).

Since DAC operates on point-in-time snapshots of a suspended process, this is safe for typical scenarios. However, if any right-side debugger code caches this handle value across process resume/suspend cycles, it could reference a dangling stack address (the ExInfo may have been popped). This is unlikely given the ICorDebug protocol re-queries state after resume, but warrants confirmation from someone familiar with the right-side implementation.

✅ Interop Changes — ObjC exception propagation correctly updated

The GetPropagatingExceptionCallback signature change from OBJECTHANDLE to OBJECTREF is correct. The caller in exceptionhandling.cpp:3957 passes pTopExInfo->m_exception directly. The callee properly GCPROTECT_BEGINs the OBJECTREF before making calls that could trigger GC. The mode contract correctly changed from MODE_PREEMPTIVE to MODE_COOPERATIVE.

✅ AsmOffsets — Field offset adjustments are consistent

Removing the 8-byte m_hThrowable field shifts subsequent field offsets down by 8 bytes on 64-bit (0xa8→0xa0, 0xb0→0xa8, etc.) and 4 bytes on 32-bit (0x5c→0x58, 0x60→0x5c, etc.). The AsmOffsets.cs changes reflect exactly this shift.

✅ cDAC Contract Updates — Correct adaptation

The cDAC changes correctly:

  • Change ExceptionInfo.ThrownObjectHandle (ObjectHandle) to ThrownObject (pointer)
  • Compute the pseudo-handle by adding the field offset to the ExInfo address
  • Short-circuit to null when ThrownObject == TargetPointer.Null
  • Tests are updated to verify read-through semantics (asserting the pointer dereferences to the expected object)

✅ Thread Safety — No races in the scanning path

The ExInfo chain is only modified on the owning thread (exception dispatch is per-thread), and GC scanning only occurs when threads are suspended. There is no race between writing m_exception and reading it during GC.

💡 Minor — prestub.cpp AppendElement calls

In prestub.cpp:1929 and 2134, the code now does StackTraceInfo::AppendElement(ObjectFromHandle(ohThrowable), ...). The handle here is LastThrownObjectHandle() (a real GC handle, not a pseudo-handle), so this is correct. Just noting the pattern is intentionally different from the ExInfo path.

Generated by Code Review for issue #127300 ·

The ExInfo::m_exception slot is GC-reported via the ExInfo chain scanner in ScanStackRoots (gcenv.ee.cpp). Reporting the same OBJECTREF location to the GC twice (once via GCPROTECT, once via the chain scanner) corrupts the relocate phase: the slot gets relocated twice, producing a garbage pointer that AVs on the next read.
This was missed in the earlier conversion of the three sites in exceptionhandling.cpp. It was reproducible on x86 checked builds running JitTest_throw_SEH, where SEH (hardware faults) routes through HandleManagedFault.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 04:07

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

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 28 out of 28 changed files in this pull request and generated no new comments.

@max-charlamb

Copy link
Copy Markdown
MemberAuthor

/ba-g Test failures are unrelated to PR changes

@max-charlamb
max-charlamb merged commit dc3d5a0 into mainMay 1, 2026
107 of 119 checks passed
@max-charlamb
max-charlamb deleted the dev/max-charlamb/exception-direct-pointer branch May 1, 2026 14:30
max-charlamb added a commit to max-charlamb/runtime that referenced this pull request May 1, 2026
Two changes responding to PR review feedback:
1. Remove dead CEEInfo::HandleException.
The function has been unreachable since 2016 (commit 4d9f4b8 `Remove SEH interactions between the JIT and the EE'') which replaced the old ICorJitInfo::FilterException/HandleException pair with runWithErrorTrap. The function is private, non-virtual, not part of the ICorJitInfo interface, and has zero callers in coreclr, the JIT, the AOT thunks, or SuperPMI (the SuperPMI Packet_HandleException slot is commented out). Removing it also retires the long-stale comment about `sync between the LTO and the exception tracker'' that pre-dates the ExInfo redesign and the lazy-LTO model from dotnet#127300/dotnet#127649.
2. Reorder declarations in threads.h so SetLastThrownObject precedes SetSOForLastThrownObject, matching the order of the definitions in threads.cpp.
Also update an unrelated stale comment in ExInfo::PopExInfos: the `unmanaged thread'' rationale is incorrect because both UMThunkUnwindFrameChainHandler and CallDescrWorkerUnwindFrameChainHandler short-circuit unmanaged threads before reaching PopExInfos, and the function carries a MODE_COOPERATIVE contract.
No behavior change.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
hoyosjs added a commit that referenced this pull request May 5, 2026
…xception dispatch (#127741)
After #127300 removed ExInfo::m_hThrowable and SetThrowable(),
Thread::m_LastThrownObjectHandle is no longer updated during active
exception dispatch. This causes a staleclastThrownObjectHandle to ge
returned by GetThreadData when a debugger breaks into the target
mid-dispatch.
When debugging a .NET 11 process with SOS (e.g. via dotnet-dump or
WinDbg), the following commands fail if the debuggee is stopped during
exception dispatch (for example, with SXE CLR):
- !PrintException — reports "Invalid exception object" because it
dereferences the stale handle and gets a null or recycled pointer
- !Threads — shows no exception column for threads that are actively
throwing
- !clrstack -a with exception context — may show incomplete or missing
exception info
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 1, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@janvorli@jkotas