Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception - #127649

Closed
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2
Closed

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception#127649
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2

Conversation

@max-charlamb

@max-charlambmax-charlamb commented May 1, 2026

Copy link
Copy Markdown
Member

Note

This PR was authored with the assistance of GitHub Copilot.

Summary

Follow-up to #127300. Stop eagerly synchronizing Thread::m_LastThrownObjectHandle with ExInfo::m_exception during active exception dispatch. Instead, set LTO lazily when the ExInfo is destroyed in PopExInfos.

After #127300 made m_exception the GC-tracked exception object during dispatch, the eager LTO sync became redundant cost — every throw and dispatch transition was creating/updating a GCHandle that nothing on the dispatch path actually consumes (consumers read m_exception directly, or only need LTO once dispatch unwinds).

Key Changes

  • Lazy LTO update: ExInfo::~ExInfo (via PopExInfos) writes m_exception into m_LastThrownObjectHandle exactly once, when the exception leaves the dispatch chain. m_exception remains the sole source of truth during dispatch.
  • Remove SafeSetThrowables — the dual-write helper is no longer needed.
  • Remove SafeUpdateLastThrownObject — eager sync sites are gone.
  • Remove SyncManagedExceptionState and the InternalUnhandledExceptionFilter re-sync block.

Fold the redundant create-then-destroy in CallCatchFunclet

Previously, the simple try/throw/catch path did one wasteful GCHandle create + destroy per exception:

  1. PopExInfos would SetLastThrownObject(m_exception) per popped ExInfo (creating a fresh handle each iteration).
  2. CallCatchFunclet would immediately follow with if (!IsExceptionInProgress()) SetLastThrownObject(NULL) (destroying the handle just created).

PopExInfos now:

  • Tracks the bottommost popped m_exception in a local OBJECTREF and writes LTO once post-loop instead of per-iteration.
  • Accepts an optional clearLtoIfChainEmpties flag. When the chain becomes empty (no outer ExInfo), the catch-funclet caller passes true so the post-loop branch collapses into a single SetLastThrownObject(NULL) — itself a no-op when LTO was already NULL.

Net work for the common DispatchManagedException + simple managed catch case: zero GCHandle operations (vs one create + one destroy before). The bridging cases (search pass, second-pass unwind to native EX_CATCH, debugger interception) are unchanged: they still populate LTO with the bottommost popped m_exception so native EX_CATCH consumers can read it after the ExInfo is gone.

Cleanup of dead exception-handling helpers

While reviewing this PR, @jkotas pointed out additional dead code that the lazy-LTO change exposed, plus other latent dead code in the area:

  • Merge SafeSetLastThrownObject into SetLastThrownObject — single public method, NOTHROW contract. The EX_TRY/EX_CATCH now wraps only the throwing CreateHandle call; observable behavior on the OOM fallback path is unchanged.
  • Delete Thread::SetLastThrownObjectHandle — zero callers post-PR.
  • Delete SetManagedUnhandledExceptionBit forward declaration — no definition existed anywhere in the codebase.
  • Drop the always-NULL pThrowableIn parameter of NotifyAppDomainsOfUnhandledException.
  • Replace UpdateCurrentThrowable (whose name was misleading: it never updated anything, only returned a boolean) with an inline check at the single call site using the GC-mode-agnostic IsThrowableNull / IsLastThrownObjectNull helpers.
  • Delete CEEInfo::HandleException — unreachable since 2016 (4d9f4b8 "Remove SEH interactions between the JIT and the EE" 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. Removing it also retires a long-stale comment about "sync between the LTO and the exception tracker" that pre-dates the ExInfo redesign.

What stays unchanged

  • m_LastThrownObjectHandle itself remains an OBJECTHANDLE — required by the ICorDebug managed debugging protocol, which reads it cross-process via BuildFromGCHandle.
  • GetCurrentException / GetThreadException still prefer the live ExInfo::m_exception (via pseudo-handle) and only fall back to LTO when no ExInfo is active — same precedence as Remove ExInfo::m_hThrowable - use direct pointer for exception objects #127300.
  • Preallocated-exception handling (SO, OOM) is preserved.
  • The m_ltoIsUnhandled flag is kept — it is still set TRUE from EEPolicy::HandleFatalError / HandleFatalStackOverflow and consumed by the managed debugger via DAC and cDAC.

Related

Testing

  • Ran internal diagnostics tests with no regressions

…eagerly
Stop eagerly synchronizing m_LastThrownObjectHandle with ExInfo::m_exception
during active exception dispatch. Instead, set LTO lazily when the ExInfo is
destroyed in PopExInfos. This simplifies the code and makes m_exception the
sole source of truth during dispatch.
Removed: SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState,
and the InternalUnhandledExceptionFilter re-sync block.
Made SetLastThrownObject private; all external callers now use
SafeSetLastThrownObject which handles OOM gracefully.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 14:51
@github-actionsgithub-actionsBot added the area-ExceptionHandling-coreclr only use for closed issues label May 1, 2026
@max-charlamb

This comment was marked as outdated.

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 refactors CoreCLR exception state tracking to avoid eagerly synchronizing Thread::m_LastThrownObjectHandle (LTO) with ExInfo::m_exception during active exception dispatch, instead updating LTO lazily when ExInfo entries are popped. The intent is to reduce per-throw overhead after ExInfo::m_exception became the primary GC-tracked exception object during dispatch (per #127300).

Changes:

  • Update LTO lazily in ExInfo::PopExInfos from ExInfo::m_exception as each tracker is popped.
  • Remove eager-sync helpers/paths (SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState) and switch call sites to SafeSetLastThrownObject.
  • Restrict direct Thread::SetLastThrownObject usage by making it private and routing external callers through SafeSetLastThrownObject (now optionally marks unhandled).

Reviewed changes

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

Show a summary per file
FileDescription
src/coreclr/vm/threads.hUpdates LTO documentation, removes eager-sync APIs, makes SetLastThrownObject private, extends SafeSetLastThrownObject signature.
src/coreclr/vm/threads.cppRemoves SafeSetThrowables/SafeUpdateLastThrownObject implementations; updates thread teardown to clear LTO via SafeSetLastThrownObject.
src/coreclr/vm/exinfo.cppAdds lazy LTO update when popping ExInfo entries.
src/coreclr/vm/exceptionhandling.cppRemoves eager LTO sync at first-chance notification and removes SyncManagedExceptionState call after catch funclets.
src/coreclr/vm/excep.cppRemoves unhandled-exception filter resync of LTO against the active tracker.
src/coreclr/vm/eepolicy.cppRoutes fatal error/SO paths through SafeSetLastThrownObject(..., TRUE) instead of SetLastThrownObject / SafeSetThrowables.
src/coreclr/vm/comutilnative.cppUses SafeSetLastThrownObject in FailFast path.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
The reviewer noted that PopExInfos now reads m_exception (an OBJECTREF) and
calls Thread::SafeSetLastThrownObject, which requires MODE_COOPERATIVE for
non-NULL throwables, without an explicit GC mode contract.
All six callers were already cooperative:
- exceptionhandling.cpp:601 - explicit GCX_COOP() three lines above
- CleanUpForSecondPass callers (lines 2084/2139/2148) - the asm stub puts
the thread in COOP before entering, and the SO path uses GCX_COOP_NO_DTOR
- CallCatchFunclet (line 3185) - has MODE_COOPERATIVE CONTRACTL itself
- ResumeAtInterceptionLocation (line 3300) - catch-dispatch path also calls
PopExplicitFrames, which already requires cooperative mode
Add the contract explicitly so that future callers cannot violate it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Cleans up dead code identified by @jkotas in PR dotnet#127649 review:
- Delete Thread::SetLastThrownObjectHandle: zero callers post-PR.
- Delete SetManagedUnhandledExceptionBit forward declaration: had no
definition anywhere in the codebase.
- Remove the always-NULL pThrowableIn parameter from
NotifyAppDomainsOfUnhandledException.
- Replace UpdateCurrentThrowable (whose name was misleading: it never
updated anything, only returned a boolean) with an inline check at
the single call site using the GC-mode-agnostic IsThrowableNull /
IsLastThrownObjectNull helpers, removing the need for a GCX_COOP
inside the surrounding PAL_TRY block.
- Merge SafeSetLastThrownObject into SetLastThrownObject: a single
public method whose contract reflects the safe NOTHROW behavior.
The EX_TRY/EX_CATCH wraps only the throwing CreateHandle call;
observable behavior on the OOM fallback path is unchanged.
- Drop a stale `similar to UpdateCurrentThrowable()` comment in
eedbginterfaceimpl.cpp.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 20:13

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

Comment threadsrc/coreclr/vm/exinfo.cpp
Comment threadsrc/coreclr/vm/threads.h
Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
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>
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from 09de907 to 70188bbCompareMay 1, 2026 21:20
@max-charlamb
max-charlamb requested a review from jkotasMay 1, 2026 21:23
Comment threadsrc/coreclr/vm/excep.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
#endif // DEBUGGING_SUPPORTED

// Set LTO from the exception being destroyed so that post-ExInfo consumers

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.

We should do this only when it is actually going to be needed. For example, in a simple try { throw new Exception(); } catch { }, PopExInfos is going to be called from CallCatchFunclets, we create the LTO handle and the very next line in CallCatchFunclets is going to delete the LTO handle.

pThread->SetLastThrownObject(NULL);
}

// Sync managed exception state, for the managed thread, based upon any active exception tracker

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.

Is this going to cause a behavior change in things like the windbg !pe command ?

Before this change, !pe issued while the thread is executing a catch block is going to print the exception that's being caught. After this change, I think it is going to print nothing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(It would be nice if we can fix this without short-lived GCHandle allocation.)

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.

@jkotas, I am not sure I understand the comment about the !pe, this code is executed after the catch funclet has returned.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

It seems like I inadvertently broke this in the last PR, Juan has #127741 that fixes this by checking m_exception before LTO.

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.

Yes, this comment is attached to a wrong line.

I think it is still valid. In main, !pe is going to print the caught exception when the program is stopped inside a catch block in main, but not anymore with this change.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think #127741 should cover this. !pe reads DacpThreadData.lastThrownObjectHandle. That struct value is the following after Jaun's change:

// Prefer the active exception from ExInfo (pseudo-handle to m_exception field).// After the removal of SetThrowable/m_hThrowable, m_LastThrownObjectHandle is only// updated after exception dispatch completes, so during active dispatch it may be// stale. GetThrowableAsPseudoHandle returns the address of ExInfo::m_exception// which has the same dereference semantics as a real GC handle.
{
OBJECTHANDLE ohException = thread->GetThrowableAsPseudoHandle();
if (ohException == (OBJECTHANDLE)NULL)
{
ohException = thread->m_LastThrownObjectHandle;
}
threadData->lastThrownObjectHandle = TO_CDADDR(ohException);
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

There are some other cases where this logic needs to be applied. I will fix it more generally.

Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings May 4, 2026 16:51

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
{
pThread->SafeSetLastThrownObject(NULL);
pThread->SetLastThrownObject(NULL);
}
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@EgorBot -intel -amd

usingBenchmarkDotNet.Attributes;usingSystem.Runtime.CompilerServices;[MemoryDiagnoser]publicclassExceptionBench{[Benchmark]publicintSimpleThrowCatch(){try{thrownewInvalidOperationException("x");}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintRethrow(){try{try{thrownewInvalidOperationException("x");}catch{throw;}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintCatchAndThrowNew(){try{try{thrownewInvalidOperationException("inner");}catch{thrownewApplicationException("outer");}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintNestedThrowCatch(){intr=0;try{Outer();}catch(Exceptione){r=e.Message.Length;}returnr;}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidOuter(){try{Inner();}catch(InvalidOperationException){thrownewApplicationException("rewrapped");}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidInner()=>thrownewInvalidOperationException("x");[Benchmark]publicintDeepThrowCatch(){try{Recurse(20);return0;}catch(Exceptione){returne.Message.Length;}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidRecurse(intn){if(n==0)thrownewInvalidOperationException("deep");Recurse(n-1);}}

The interesting cases here:

BenchWhat it stresses
SimpleThrowCatchOne ExInfo lifetime — was 1 eager LTO write + 1 reset; now 1 write at PopExInfos
RethrowSafeUpdateLastThrownObject on rethrow — entirely removed
CatchAndThrowNewNew throw inside a catch — an extra eager LTO sync per dispatch transition
NestedThrowCatchMulti-frame transition: previously two eager writes, now one lazy write
DeepThrowCatchPure unwind cost (control: shouldn't change much)

Expecting the rethrow/catch-and-throw-new paths to show the largest delta since those are the dispatch transitions where SafeUpdateLastThrownObject / SafeSetThrowables used to run.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
pThread->GetExceptionState()->m_pCurrentTracker = pExInfo;

if (pExInfo == NULL && clearLtoIfChainEmpties)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I do not think the synchronization of the LTO should depend on whether there are any trackers left on the thread. The caller should either expects the Lto to be set or not, irrespective of what other exception handling may be present upstack.

The argument may want to be called setLtoToPoppedException

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

In the CallCatchFunclets case we can not set LTO inside of PopExinfos and unconditionally NULL it out afterwards. This should still prevent the extra creation in the case we care about.

CopilotAI review requested due to automatic review settings May 4, 2026 18:15
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from a163d98 to 7ac958aCompareMay 4, 2026 18: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 11 out of 11 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/vm/exceptionhandling.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlamb marked this pull request as draft May 4, 2026 22:00
Replace implicit `Thread::LastThrownObject` / `GetThrowable` /
`IsThrowableNull` family with source-explicit accessors that name the
view the caller wants:
- `Thread::GetThrowableHandle(ThrowableSource)`
- `Thread::GetThrowableRef(ThrowableSource)`
- `Thread::IsThrowableNull(ThrowableSource)`
`ThrowableSource` enum values: `ExInfoOnly`, `LTOOnly`, `ExInfoOrLTO`,
`LTOIfUnhandled`, `ExInfoOrLTOIfUnhandled`. Callers explicitly select
the view they want instead of relying on the (now lazy) LTO field
being coherent with the active ExInfo.
Migrated all 36 reader sites in CoreCLR (VM, EE, profiling, ETW,
prestub/interp, runtime EH, fatal/Watson, debugger DBI, DAC). Removed
seven legacy wrappers from `threads.h`: `GetThrowable`, `HasException`,
`GetThrowableAsPseudoHandle`, `IsThrowableNull()` no-arg,
`IsLastThrownObjectNull`, `LastThrownObject`, `LastThrownObjectHandle`,
plus `IsLastThrownObjectStackOverflowException` (one caller inlined).
Also fixes a Reflection.Invoke crash on the lazy branch:
`CallDescrWorkerUnwindFrameChainHandler`'s non-SO unwind path called
`CleanUpForSecondPass` -> `PopExInfos` from PREEMP, but the lazy
`PopExInfos` reads `OBJECTREF` and requires COOP. Wrapped with
`GCX_COOP()` in exceptionhandling.cpp.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 11, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-ExceptionHandling-coreclronly use for closed issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@jkotas@janvorli
, '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

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception - #127649

Closed
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2
Closed

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception#127649
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2

Conversation

@max-charlamb

@max-charlambmax-charlamb commented May 1, 2026

Copy link
Copy Markdown
Member

Note

This PR was authored with the assistance of GitHub Copilot.

Summary

Follow-up to #127300. Stop eagerly synchronizing Thread::m_LastThrownObjectHandle with ExInfo::m_exception during active exception dispatch. Instead, set LTO lazily when the ExInfo is destroyed in PopExInfos.

After #127300 made m_exception the GC-tracked exception object during dispatch, the eager LTO sync became redundant cost — every throw and dispatch transition was creating/updating a GCHandle that nothing on the dispatch path actually consumes (consumers read m_exception directly, or only need LTO once dispatch unwinds).

Key Changes

  • Lazy LTO update: ExInfo::~ExInfo (via PopExInfos) writes m_exception into m_LastThrownObjectHandle exactly once, when the exception leaves the dispatch chain. m_exception remains the sole source of truth during dispatch.
  • Remove SafeSetThrowables — the dual-write helper is no longer needed.
  • Remove SafeUpdateLastThrownObject — eager sync sites are gone.
  • Remove SyncManagedExceptionState and the InternalUnhandledExceptionFilter re-sync block.

Fold the redundant create-then-destroy in CallCatchFunclet

Previously, the simple try/throw/catch path did one wasteful GCHandle create + destroy per exception:

  1. PopExInfos would SetLastThrownObject(m_exception) per popped ExInfo (creating a fresh handle each iteration).
  2. CallCatchFunclet would immediately follow with if (!IsExceptionInProgress()) SetLastThrownObject(NULL) (destroying the handle just created).

PopExInfos now:

  • Tracks the bottommost popped m_exception in a local OBJECTREF and writes LTO once post-loop instead of per-iteration.
  • Accepts an optional clearLtoIfChainEmpties flag. When the chain becomes empty (no outer ExInfo), the catch-funclet caller passes true so the post-loop branch collapses into a single SetLastThrownObject(NULL) — itself a no-op when LTO was already NULL.

Net work for the common DispatchManagedException + simple managed catch case: zero GCHandle operations (vs one create + one destroy before). The bridging cases (search pass, second-pass unwind to native EX_CATCH, debugger interception) are unchanged: they still populate LTO with the bottommost popped m_exception so native EX_CATCH consumers can read it after the ExInfo is gone.

Cleanup of dead exception-handling helpers

While reviewing this PR, @jkotas pointed out additional dead code that the lazy-LTO change exposed, plus other latent dead code in the area:

  • Merge SafeSetLastThrownObject into SetLastThrownObject — single public method, NOTHROW contract. The EX_TRY/EX_CATCH now wraps only the throwing CreateHandle call; observable behavior on the OOM fallback path is unchanged.
  • Delete Thread::SetLastThrownObjectHandle — zero callers post-PR.
  • Delete SetManagedUnhandledExceptionBit forward declaration — no definition existed anywhere in the codebase.
  • Drop the always-NULL pThrowableIn parameter of NotifyAppDomainsOfUnhandledException.
  • Replace UpdateCurrentThrowable (whose name was misleading: it never updated anything, only returned a boolean) with an inline check at the single call site using the GC-mode-agnostic IsThrowableNull / IsLastThrownObjectNull helpers.
  • Delete CEEInfo::HandleException — unreachable since 2016 (4d9f4b8 "Remove SEH interactions between the JIT and the EE" 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. Removing it also retires a long-stale comment about "sync between the LTO and the exception tracker" that pre-dates the ExInfo redesign.

What stays unchanged

  • m_LastThrownObjectHandle itself remains an OBJECTHANDLE — required by the ICorDebug managed debugging protocol, which reads it cross-process via BuildFromGCHandle.
  • GetCurrentException / GetThreadException still prefer the live ExInfo::m_exception (via pseudo-handle) and only fall back to LTO when no ExInfo is active — same precedence as Remove ExInfo::m_hThrowable - use direct pointer for exception objects #127300.
  • Preallocated-exception handling (SO, OOM) is preserved.
  • The m_ltoIsUnhandled flag is kept — it is still set TRUE from EEPolicy::HandleFatalError / HandleFatalStackOverflow and consumed by the managed debugger via DAC and cDAC.

Related

Testing

  • Ran internal diagnostics tests with no regressions

…eagerly
Stop eagerly synchronizing m_LastThrownObjectHandle with ExInfo::m_exception
during active exception dispatch. Instead, set LTO lazily when the ExInfo is
destroyed in PopExInfos. This simplifies the code and makes m_exception the
sole source of truth during dispatch.
Removed: SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState,
and the InternalUnhandledExceptionFilter re-sync block.
Made SetLastThrownObject private; all external callers now use
SafeSetLastThrownObject which handles OOM gracefully.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 14:51
@github-actionsgithub-actionsBot added the area-ExceptionHandling-coreclr only use for closed issues label May 1, 2026
@max-charlamb

This comment was marked as outdated.

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 refactors CoreCLR exception state tracking to avoid eagerly synchronizing Thread::m_LastThrownObjectHandle (LTO) with ExInfo::m_exception during active exception dispatch, instead updating LTO lazily when ExInfo entries are popped. The intent is to reduce per-throw overhead after ExInfo::m_exception became the primary GC-tracked exception object during dispatch (per #127300).

Changes:

  • Update LTO lazily in ExInfo::PopExInfos from ExInfo::m_exception as each tracker is popped.
  • Remove eager-sync helpers/paths (SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState) and switch call sites to SafeSetLastThrownObject.
  • Restrict direct Thread::SetLastThrownObject usage by making it private and routing external callers through SafeSetLastThrownObject (now optionally marks unhandled).

Reviewed changes

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

Show a summary per file
FileDescription
src/coreclr/vm/threads.hUpdates LTO documentation, removes eager-sync APIs, makes SetLastThrownObject private, extends SafeSetLastThrownObject signature.
src/coreclr/vm/threads.cppRemoves SafeSetThrowables/SafeUpdateLastThrownObject implementations; updates thread teardown to clear LTO via SafeSetLastThrownObject.
src/coreclr/vm/exinfo.cppAdds lazy LTO update when popping ExInfo entries.
src/coreclr/vm/exceptionhandling.cppRemoves eager LTO sync at first-chance notification and removes SyncManagedExceptionState call after catch funclets.
src/coreclr/vm/excep.cppRemoves unhandled-exception filter resync of LTO against the active tracker.
src/coreclr/vm/eepolicy.cppRoutes fatal error/SO paths through SafeSetLastThrownObject(..., TRUE) instead of SetLastThrownObject / SafeSetThrowables.
src/coreclr/vm/comutilnative.cppUses SafeSetLastThrownObject in FailFast path.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
The reviewer noted that PopExInfos now reads m_exception (an OBJECTREF) and
calls Thread::SafeSetLastThrownObject, which requires MODE_COOPERATIVE for
non-NULL throwables, without an explicit GC mode contract.
All six callers were already cooperative:
- exceptionhandling.cpp:601 - explicit GCX_COOP() three lines above
- CleanUpForSecondPass callers (lines 2084/2139/2148) - the asm stub puts
the thread in COOP before entering, and the SO path uses GCX_COOP_NO_DTOR
- CallCatchFunclet (line 3185) - has MODE_COOPERATIVE CONTRACTL itself
- ResumeAtInterceptionLocation (line 3300) - catch-dispatch path also calls
PopExplicitFrames, which already requires cooperative mode
Add the contract explicitly so that future callers cannot violate it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Cleans up dead code identified by @jkotas in PR dotnet#127649 review:
- Delete Thread::SetLastThrownObjectHandle: zero callers post-PR.
- Delete SetManagedUnhandledExceptionBit forward declaration: had no
definition anywhere in the codebase.
- Remove the always-NULL pThrowableIn parameter from
NotifyAppDomainsOfUnhandledException.
- Replace UpdateCurrentThrowable (whose name was misleading: it never
updated anything, only returned a boolean) with an inline check at
the single call site using the GC-mode-agnostic IsThrowableNull /
IsLastThrownObjectNull helpers, removing the need for a GCX_COOP
inside the surrounding PAL_TRY block.
- Merge SafeSetLastThrownObject into SetLastThrownObject: a single
public method whose contract reflects the safe NOTHROW behavior.
The EX_TRY/EX_CATCH wraps only the throwing CreateHandle call;
observable behavior on the OOM fallback path is unchanged.
- Drop a stale `similar to UpdateCurrentThrowable()` comment in
eedbginterfaceimpl.cpp.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 20:13

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

Comment threadsrc/coreclr/vm/exinfo.cpp
Comment threadsrc/coreclr/vm/threads.h
Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
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>
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from 09de907 to 70188bbCompareMay 1, 2026 21:20
@max-charlamb
max-charlamb requested a review from jkotasMay 1, 2026 21:23
Comment threadsrc/coreclr/vm/excep.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
#endif // DEBUGGING_SUPPORTED

// Set LTO from the exception being destroyed so that post-ExInfo consumers

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.

We should do this only when it is actually going to be needed. For example, in a simple try { throw new Exception(); } catch { }, PopExInfos is going to be called from CallCatchFunclets, we create the LTO handle and the very next line in CallCatchFunclets is going to delete the LTO handle.

pThread->SetLastThrownObject(NULL);
}

// Sync managed exception state, for the managed thread, based upon any active exception tracker

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.

Is this going to cause a behavior change in things like the windbg !pe command ?

Before this change, !pe issued while the thread is executing a catch block is going to print the exception that's being caught. After this change, I think it is going to print nothing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(It would be nice if we can fix this without short-lived GCHandle allocation.)

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.

@jkotas, I am not sure I understand the comment about the !pe, this code is executed after the catch funclet has returned.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

It seems like I inadvertently broke this in the last PR, Juan has #127741 that fixes this by checking m_exception before LTO.

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.

Yes, this comment is attached to a wrong line.

I think it is still valid. In main, !pe is going to print the caught exception when the program is stopped inside a catch block in main, but not anymore with this change.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think #127741 should cover this. !pe reads DacpThreadData.lastThrownObjectHandle. That struct value is the following after Jaun's change:

// Prefer the active exception from ExInfo (pseudo-handle to m_exception field).// After the removal of SetThrowable/m_hThrowable, m_LastThrownObjectHandle is only// updated after exception dispatch completes, so during active dispatch it may be// stale. GetThrowableAsPseudoHandle returns the address of ExInfo::m_exception// which has the same dereference semantics as a real GC handle.
{
OBJECTHANDLE ohException = thread->GetThrowableAsPseudoHandle();
if (ohException == (OBJECTHANDLE)NULL)
{
ohException = thread->m_LastThrownObjectHandle;
}
threadData->lastThrownObjectHandle = TO_CDADDR(ohException);
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

There are some other cases where this logic needs to be applied. I will fix it more generally.

Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings May 4, 2026 16:51

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
{
pThread->SafeSetLastThrownObject(NULL);
pThread->SetLastThrownObject(NULL);
}
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@EgorBot -intel -amd

usingBenchmarkDotNet.Attributes;usingSystem.Runtime.CompilerServices;[MemoryDiagnoser]publicclassExceptionBench{[Benchmark]publicintSimpleThrowCatch(){try{thrownewInvalidOperationException("x");}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintRethrow(){try{try{thrownewInvalidOperationException("x");}catch{throw;}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintCatchAndThrowNew(){try{try{thrownewInvalidOperationException("inner");}catch{thrownewApplicationException("outer");}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintNestedThrowCatch(){intr=0;try{Outer();}catch(Exceptione){r=e.Message.Length;}returnr;}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidOuter(){try{Inner();}catch(InvalidOperationException){thrownewApplicationException("rewrapped");}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidInner()=>thrownewInvalidOperationException("x");[Benchmark]publicintDeepThrowCatch(){try{Recurse(20);return0;}catch(Exceptione){returne.Message.Length;}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidRecurse(intn){if(n==0)thrownewInvalidOperationException("deep");Recurse(n-1);}}

The interesting cases here:

BenchWhat it stresses
SimpleThrowCatchOne ExInfo lifetime — was 1 eager LTO write + 1 reset; now 1 write at PopExInfos
RethrowSafeUpdateLastThrownObject on rethrow — entirely removed
CatchAndThrowNewNew throw inside a catch — an extra eager LTO sync per dispatch transition
NestedThrowCatchMulti-frame transition: previously two eager writes, now one lazy write
DeepThrowCatchPure unwind cost (control: shouldn't change much)

Expecting the rethrow/catch-and-throw-new paths to show the largest delta since those are the dispatch transitions where SafeUpdateLastThrownObject / SafeSetThrowables used to run.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
pThread->GetExceptionState()->m_pCurrentTracker = pExInfo;

if (pExInfo == NULL && clearLtoIfChainEmpties)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I do not think the synchronization of the LTO should depend on whether there are any trackers left on the thread. The caller should either expects the Lto to be set or not, irrespective of what other exception handling may be present upstack.

The argument may want to be called setLtoToPoppedException

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

In the CallCatchFunclets case we can not set LTO inside of PopExinfos and unconditionally NULL it out afterwards. This should still prevent the extra creation in the case we care about.

CopilotAI review requested due to automatic review settings May 4, 2026 18:15
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from a163d98 to 7ac958aCompareMay 4, 2026 18: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 11 out of 11 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/vm/exceptionhandling.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlamb marked this pull request as draft May 4, 2026 22:00
Replace implicit `Thread::LastThrownObject` / `GetThrowable` /
`IsThrowableNull` family with source-explicit accessors that name the
view the caller wants:
- `Thread::GetThrowableHandle(ThrowableSource)`
- `Thread::GetThrowableRef(ThrowableSource)`
- `Thread::IsThrowableNull(ThrowableSource)`
`ThrowableSource` enum values: `ExInfoOnly`, `LTOOnly`, `ExInfoOrLTO`,
`LTOIfUnhandled`, `ExInfoOrLTOIfUnhandled`. Callers explicitly select
the view they want instead of relying on the (now lazy) LTO field
being coherent with the active ExInfo.
Migrated all 36 reader sites in CoreCLR (VM, EE, profiling, ETW,
prestub/interp, runtime EH, fatal/Watson, debugger DBI, DAC). Removed
seven legacy wrappers from `threads.h`: `GetThrowable`, `HasException`,
`GetThrowableAsPseudoHandle`, `IsThrowableNull()` no-arg,
`IsLastThrownObjectNull`, `LastThrownObject`, `LastThrownObjectHandle`,
plus `IsLastThrownObjectStackOverflowException` (one caller inlined).
Also fixes a Reflection.Invoke crash on the lazy branch:
`CallDescrWorkerUnwindFrameChainHandler`'s non-SO unwind path called
`CleanUpForSecondPass` -> `PopExInfos` from PREEMP, but the lazy
`PopExInfos` reads `OBJECTREF` and requires COOP. Wrapped with
`GCX_COOP()` in exceptionhandling.cpp.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 11, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-ExceptionHandling-coreclronly use for closed issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@jkotas@janvorli
, '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

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception - #127649

Closed
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2
Closed

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception#127649
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2

Conversation

@max-charlamb

@max-charlambmax-charlamb commented May 1, 2026

Copy link
Copy Markdown
Member

Note

This PR was authored with the assistance of GitHub Copilot.

Summary

Follow-up to #127300. Stop eagerly synchronizing Thread::m_LastThrownObjectHandle with ExInfo::m_exception during active exception dispatch. Instead, set LTO lazily when the ExInfo is destroyed in PopExInfos.

After #127300 made m_exception the GC-tracked exception object during dispatch, the eager LTO sync became redundant cost — every throw and dispatch transition was creating/updating a GCHandle that nothing on the dispatch path actually consumes (consumers read m_exception directly, or only need LTO once dispatch unwinds).

Key Changes

  • Lazy LTO update: ExInfo::~ExInfo (via PopExInfos) writes m_exception into m_LastThrownObjectHandle exactly once, when the exception leaves the dispatch chain. m_exception remains the sole source of truth during dispatch.
  • Remove SafeSetThrowables — the dual-write helper is no longer needed.
  • Remove SafeUpdateLastThrownObject — eager sync sites are gone.
  • Remove SyncManagedExceptionState and the InternalUnhandledExceptionFilter re-sync block.

Fold the redundant create-then-destroy in CallCatchFunclet

Previously, the simple try/throw/catch path did one wasteful GCHandle create + destroy per exception:

  1. PopExInfos would SetLastThrownObject(m_exception) per popped ExInfo (creating a fresh handle each iteration).
  2. CallCatchFunclet would immediately follow with if (!IsExceptionInProgress()) SetLastThrownObject(NULL) (destroying the handle just created).

PopExInfos now:

  • Tracks the bottommost popped m_exception in a local OBJECTREF and writes LTO once post-loop instead of per-iteration.
  • Accepts an optional clearLtoIfChainEmpties flag. When the chain becomes empty (no outer ExInfo), the catch-funclet caller passes true so the post-loop branch collapses into a single SetLastThrownObject(NULL) — itself a no-op when LTO was already NULL.

Net work for the common DispatchManagedException + simple managed catch case: zero GCHandle operations (vs one create + one destroy before). The bridging cases (search pass, second-pass unwind to native EX_CATCH, debugger interception) are unchanged: they still populate LTO with the bottommost popped m_exception so native EX_CATCH consumers can read it after the ExInfo is gone.

Cleanup of dead exception-handling helpers

While reviewing this PR, @jkotas pointed out additional dead code that the lazy-LTO change exposed, plus other latent dead code in the area:

  • Merge SafeSetLastThrownObject into SetLastThrownObject — single public method, NOTHROW contract. The EX_TRY/EX_CATCH now wraps only the throwing CreateHandle call; observable behavior on the OOM fallback path is unchanged.
  • Delete Thread::SetLastThrownObjectHandle — zero callers post-PR.
  • Delete SetManagedUnhandledExceptionBit forward declaration — no definition existed anywhere in the codebase.
  • Drop the always-NULL pThrowableIn parameter of NotifyAppDomainsOfUnhandledException.
  • Replace UpdateCurrentThrowable (whose name was misleading: it never updated anything, only returned a boolean) with an inline check at the single call site using the GC-mode-agnostic IsThrowableNull / IsLastThrownObjectNull helpers.
  • Delete CEEInfo::HandleException — unreachable since 2016 (4d9f4b8 "Remove SEH interactions between the JIT and the EE" 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. Removing it also retires a long-stale comment about "sync between the LTO and the exception tracker" that pre-dates the ExInfo redesign.

What stays unchanged

  • m_LastThrownObjectHandle itself remains an OBJECTHANDLE — required by the ICorDebug managed debugging protocol, which reads it cross-process via BuildFromGCHandle.
  • GetCurrentException / GetThreadException still prefer the live ExInfo::m_exception (via pseudo-handle) and only fall back to LTO when no ExInfo is active — same precedence as Remove ExInfo::m_hThrowable - use direct pointer for exception objects #127300.
  • Preallocated-exception handling (SO, OOM) is preserved.
  • The m_ltoIsUnhandled flag is kept — it is still set TRUE from EEPolicy::HandleFatalError / HandleFatalStackOverflow and consumed by the managed debugger via DAC and cDAC.

Related

Testing

  • Ran internal diagnostics tests with no regressions

…eagerly
Stop eagerly synchronizing m_LastThrownObjectHandle with ExInfo::m_exception
during active exception dispatch. Instead, set LTO lazily when the ExInfo is
destroyed in PopExInfos. This simplifies the code and makes m_exception the
sole source of truth during dispatch.
Removed: SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState,
and the InternalUnhandledExceptionFilter re-sync block.
Made SetLastThrownObject private; all external callers now use
SafeSetLastThrownObject which handles OOM gracefully.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 14:51
@github-actionsgithub-actionsBot added the area-ExceptionHandling-coreclr only use for closed issues label May 1, 2026
@max-charlamb

This comment was marked as outdated.

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 refactors CoreCLR exception state tracking to avoid eagerly synchronizing Thread::m_LastThrownObjectHandle (LTO) with ExInfo::m_exception during active exception dispatch, instead updating LTO lazily when ExInfo entries are popped. The intent is to reduce per-throw overhead after ExInfo::m_exception became the primary GC-tracked exception object during dispatch (per #127300).

Changes:

  • Update LTO lazily in ExInfo::PopExInfos from ExInfo::m_exception as each tracker is popped.
  • Remove eager-sync helpers/paths (SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState) and switch call sites to SafeSetLastThrownObject.
  • Restrict direct Thread::SetLastThrownObject usage by making it private and routing external callers through SafeSetLastThrownObject (now optionally marks unhandled).

Reviewed changes

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

Show a summary per file
FileDescription
src/coreclr/vm/threads.hUpdates LTO documentation, removes eager-sync APIs, makes SetLastThrownObject private, extends SafeSetLastThrownObject signature.
src/coreclr/vm/threads.cppRemoves SafeSetThrowables/SafeUpdateLastThrownObject implementations; updates thread teardown to clear LTO via SafeSetLastThrownObject.
src/coreclr/vm/exinfo.cppAdds lazy LTO update when popping ExInfo entries.
src/coreclr/vm/exceptionhandling.cppRemoves eager LTO sync at first-chance notification and removes SyncManagedExceptionState call after catch funclets.
src/coreclr/vm/excep.cppRemoves unhandled-exception filter resync of LTO against the active tracker.
src/coreclr/vm/eepolicy.cppRoutes fatal error/SO paths through SafeSetLastThrownObject(..., TRUE) instead of SetLastThrownObject / SafeSetThrowables.
src/coreclr/vm/comutilnative.cppUses SafeSetLastThrownObject in FailFast path.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
The reviewer noted that PopExInfos now reads m_exception (an OBJECTREF) and
calls Thread::SafeSetLastThrownObject, which requires MODE_COOPERATIVE for
non-NULL throwables, without an explicit GC mode contract.
All six callers were already cooperative:
- exceptionhandling.cpp:601 - explicit GCX_COOP() three lines above
- CleanUpForSecondPass callers (lines 2084/2139/2148) - the asm stub puts
the thread in COOP before entering, and the SO path uses GCX_COOP_NO_DTOR
- CallCatchFunclet (line 3185) - has MODE_COOPERATIVE CONTRACTL itself
- ResumeAtInterceptionLocation (line 3300) - catch-dispatch path also calls
PopExplicitFrames, which already requires cooperative mode
Add the contract explicitly so that future callers cannot violate it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Cleans up dead code identified by @jkotas in PR dotnet#127649 review:
- Delete Thread::SetLastThrownObjectHandle: zero callers post-PR.
- Delete SetManagedUnhandledExceptionBit forward declaration: had no
definition anywhere in the codebase.
- Remove the always-NULL pThrowableIn parameter from
NotifyAppDomainsOfUnhandledException.
- Replace UpdateCurrentThrowable (whose name was misleading: it never
updated anything, only returned a boolean) with an inline check at
the single call site using the GC-mode-agnostic IsThrowableNull /
IsLastThrownObjectNull helpers, removing the need for a GCX_COOP
inside the surrounding PAL_TRY block.
- Merge SafeSetLastThrownObject into SetLastThrownObject: a single
public method whose contract reflects the safe NOTHROW behavior.
The EX_TRY/EX_CATCH wraps only the throwing CreateHandle call;
observable behavior on the OOM fallback path is unchanged.
- Drop a stale `similar to UpdateCurrentThrowable()` comment in
eedbginterfaceimpl.cpp.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 20:13

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

Comment threadsrc/coreclr/vm/exinfo.cpp
Comment threadsrc/coreclr/vm/threads.h
Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
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>
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from 09de907 to 70188bbCompareMay 1, 2026 21:20
@max-charlamb
max-charlamb requested a review from jkotasMay 1, 2026 21:23
Comment threadsrc/coreclr/vm/excep.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
#endif // DEBUGGING_SUPPORTED

// Set LTO from the exception being destroyed so that post-ExInfo consumers

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.

We should do this only when it is actually going to be needed. For example, in a simple try { throw new Exception(); } catch { }, PopExInfos is going to be called from CallCatchFunclets, we create the LTO handle and the very next line in CallCatchFunclets is going to delete the LTO handle.

pThread->SetLastThrownObject(NULL);
}

// Sync managed exception state, for the managed thread, based upon any active exception tracker

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.

Is this going to cause a behavior change in things like the windbg !pe command ?

Before this change, !pe issued while the thread is executing a catch block is going to print the exception that's being caught. After this change, I think it is going to print nothing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(It would be nice if we can fix this without short-lived GCHandle allocation.)

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.

@jkotas, I am not sure I understand the comment about the !pe, this code is executed after the catch funclet has returned.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

It seems like I inadvertently broke this in the last PR, Juan has #127741 that fixes this by checking m_exception before LTO.

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.

Yes, this comment is attached to a wrong line.

I think it is still valid. In main, !pe is going to print the caught exception when the program is stopped inside a catch block in main, but not anymore with this change.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think #127741 should cover this. !pe reads DacpThreadData.lastThrownObjectHandle. That struct value is the following after Jaun's change:

// Prefer the active exception from ExInfo (pseudo-handle to m_exception field).// After the removal of SetThrowable/m_hThrowable, m_LastThrownObjectHandle is only// updated after exception dispatch completes, so during active dispatch it may be// stale. GetThrowableAsPseudoHandle returns the address of ExInfo::m_exception// which has the same dereference semantics as a real GC handle.
{
OBJECTHANDLE ohException = thread->GetThrowableAsPseudoHandle();
if (ohException == (OBJECTHANDLE)NULL)
{
ohException = thread->m_LastThrownObjectHandle;
}
threadData->lastThrownObjectHandle = TO_CDADDR(ohException);
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

There are some other cases where this logic needs to be applied. I will fix it more generally.

Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings May 4, 2026 16:51

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
{
pThread->SafeSetLastThrownObject(NULL);
pThread->SetLastThrownObject(NULL);
}
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@EgorBot -intel -amd

usingBenchmarkDotNet.Attributes;usingSystem.Runtime.CompilerServices;[MemoryDiagnoser]publicclassExceptionBench{[Benchmark]publicintSimpleThrowCatch(){try{thrownewInvalidOperationException("x");}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintRethrow(){try{try{thrownewInvalidOperationException("x");}catch{throw;}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintCatchAndThrowNew(){try{try{thrownewInvalidOperationException("inner");}catch{thrownewApplicationException("outer");}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintNestedThrowCatch(){intr=0;try{Outer();}catch(Exceptione){r=e.Message.Length;}returnr;}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidOuter(){try{Inner();}catch(InvalidOperationException){thrownewApplicationException("rewrapped");}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidInner()=>thrownewInvalidOperationException("x");[Benchmark]publicintDeepThrowCatch(){try{Recurse(20);return0;}catch(Exceptione){returne.Message.Length;}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidRecurse(intn){if(n==0)thrownewInvalidOperationException("deep");Recurse(n-1);}}

The interesting cases here:

BenchWhat it stresses
SimpleThrowCatchOne ExInfo lifetime — was 1 eager LTO write + 1 reset; now 1 write at PopExInfos
RethrowSafeUpdateLastThrownObject on rethrow — entirely removed
CatchAndThrowNewNew throw inside a catch — an extra eager LTO sync per dispatch transition
NestedThrowCatchMulti-frame transition: previously two eager writes, now one lazy write
DeepThrowCatchPure unwind cost (control: shouldn't change much)

Expecting the rethrow/catch-and-throw-new paths to show the largest delta since those are the dispatch transitions where SafeUpdateLastThrownObject / SafeSetThrowables used to run.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
pThread->GetExceptionState()->m_pCurrentTracker = pExInfo;

if (pExInfo == NULL && clearLtoIfChainEmpties)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I do not think the synchronization of the LTO should depend on whether there are any trackers left on the thread. The caller should either expects the Lto to be set or not, irrespective of what other exception handling may be present upstack.

The argument may want to be called setLtoToPoppedException

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

In the CallCatchFunclets case we can not set LTO inside of PopExinfos and unconditionally NULL it out afterwards. This should still prevent the extra creation in the case we care about.

CopilotAI review requested due to automatic review settings May 4, 2026 18:15
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from a163d98 to 7ac958aCompareMay 4, 2026 18: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 11 out of 11 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/vm/exceptionhandling.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlamb marked this pull request as draft May 4, 2026 22:00
Replace implicit `Thread::LastThrownObject` / `GetThrowable` /
`IsThrowableNull` family with source-explicit accessors that name the
view the caller wants:
- `Thread::GetThrowableHandle(ThrowableSource)`
- `Thread::GetThrowableRef(ThrowableSource)`
- `Thread::IsThrowableNull(ThrowableSource)`
`ThrowableSource` enum values: `ExInfoOnly`, `LTOOnly`, `ExInfoOrLTO`,
`LTOIfUnhandled`, `ExInfoOrLTOIfUnhandled`. Callers explicitly select
the view they want instead of relying on the (now lazy) LTO field
being coherent with the active ExInfo.
Migrated all 36 reader sites in CoreCLR (VM, EE, profiling, ETW,
prestub/interp, runtime EH, fatal/Watson, debugger DBI, DAC). Removed
seven legacy wrappers from `threads.h`: `GetThrowable`, `HasException`,
`GetThrowableAsPseudoHandle`, `IsThrowableNull()` no-arg,
`IsLastThrownObjectNull`, `LastThrownObject`, `LastThrownObjectHandle`,
plus `IsLastThrownObjectStackOverflowException` (one caller inlined).
Also fixes a Reflection.Invoke crash on the lazy branch:
`CallDescrWorkerUnwindFrameChainHandler`'s non-SO unwind path called
`CleanUpForSecondPass` -> `PopExInfos` from PREEMP, but the lazy
`PopExInfos` reads `OBJECTREF` and requires COOP. Wrapped with
`GCX_COOP()` in exceptionhandling.cpp.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 11, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-ExceptionHandling-coreclronly use for closed issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@jkotas@janvorli
, '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

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception - #127649

Closed
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2
Closed

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception#127649
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2

Conversation

@max-charlamb

@max-charlambmax-charlamb commented May 1, 2026

Copy link
Copy Markdown
Member

Note

This PR was authored with the assistance of GitHub Copilot.

Summary

Follow-up to #127300. Stop eagerly synchronizing Thread::m_LastThrownObjectHandle with ExInfo::m_exception during active exception dispatch. Instead, set LTO lazily when the ExInfo is destroyed in PopExInfos.

After #127300 made m_exception the GC-tracked exception object during dispatch, the eager LTO sync became redundant cost — every throw and dispatch transition was creating/updating a GCHandle that nothing on the dispatch path actually consumes (consumers read m_exception directly, or only need LTO once dispatch unwinds).

Key Changes

  • Lazy LTO update: ExInfo::~ExInfo (via PopExInfos) writes m_exception into m_LastThrownObjectHandle exactly once, when the exception leaves the dispatch chain. m_exception remains the sole source of truth during dispatch.
  • Remove SafeSetThrowables — the dual-write helper is no longer needed.
  • Remove SafeUpdateLastThrownObject — eager sync sites are gone.
  • Remove SyncManagedExceptionState and the InternalUnhandledExceptionFilter re-sync block.

Fold the redundant create-then-destroy in CallCatchFunclet

Previously, the simple try/throw/catch path did one wasteful GCHandle create + destroy per exception:

  1. PopExInfos would SetLastThrownObject(m_exception) per popped ExInfo (creating a fresh handle each iteration).
  2. CallCatchFunclet would immediately follow with if (!IsExceptionInProgress()) SetLastThrownObject(NULL) (destroying the handle just created).

PopExInfos now:

  • Tracks the bottommost popped m_exception in a local OBJECTREF and writes LTO once post-loop instead of per-iteration.
  • Accepts an optional clearLtoIfChainEmpties flag. When the chain becomes empty (no outer ExInfo), the catch-funclet caller passes true so the post-loop branch collapses into a single SetLastThrownObject(NULL) — itself a no-op when LTO was already NULL.

Net work for the common DispatchManagedException + simple managed catch case: zero GCHandle operations (vs one create + one destroy before). The bridging cases (search pass, second-pass unwind to native EX_CATCH, debugger interception) are unchanged: they still populate LTO with the bottommost popped m_exception so native EX_CATCH consumers can read it after the ExInfo is gone.

Cleanup of dead exception-handling helpers

While reviewing this PR, @jkotas pointed out additional dead code that the lazy-LTO change exposed, plus other latent dead code in the area:

  • Merge SafeSetLastThrownObject into SetLastThrownObject — single public method, NOTHROW contract. The EX_TRY/EX_CATCH now wraps only the throwing CreateHandle call; observable behavior on the OOM fallback path is unchanged.
  • Delete Thread::SetLastThrownObjectHandle — zero callers post-PR.
  • Delete SetManagedUnhandledExceptionBit forward declaration — no definition existed anywhere in the codebase.
  • Drop the always-NULL pThrowableIn parameter of NotifyAppDomainsOfUnhandledException.
  • Replace UpdateCurrentThrowable (whose name was misleading: it never updated anything, only returned a boolean) with an inline check at the single call site using the GC-mode-agnostic IsThrowableNull / IsLastThrownObjectNull helpers.
  • Delete CEEInfo::HandleException — unreachable since 2016 (4d9f4b8 "Remove SEH interactions between the JIT and the EE" 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. Removing it also retires a long-stale comment about "sync between the LTO and the exception tracker" that pre-dates the ExInfo redesign.

What stays unchanged

  • m_LastThrownObjectHandle itself remains an OBJECTHANDLE — required by the ICorDebug managed debugging protocol, which reads it cross-process via BuildFromGCHandle.
  • GetCurrentException / GetThreadException still prefer the live ExInfo::m_exception (via pseudo-handle) and only fall back to LTO when no ExInfo is active — same precedence as Remove ExInfo::m_hThrowable - use direct pointer for exception objects #127300.
  • Preallocated-exception handling (SO, OOM) is preserved.
  • The m_ltoIsUnhandled flag is kept — it is still set TRUE from EEPolicy::HandleFatalError / HandleFatalStackOverflow and consumed by the managed debugger via DAC and cDAC.

Related

Testing

  • Ran internal diagnostics tests with no regressions

…eagerly
Stop eagerly synchronizing m_LastThrownObjectHandle with ExInfo::m_exception
during active exception dispatch. Instead, set LTO lazily when the ExInfo is
destroyed in PopExInfos. This simplifies the code and makes m_exception the
sole source of truth during dispatch.
Removed: SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState,
and the InternalUnhandledExceptionFilter re-sync block.
Made SetLastThrownObject private; all external callers now use
SafeSetLastThrownObject which handles OOM gracefully.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 14:51
@github-actionsgithub-actionsBot added the area-ExceptionHandling-coreclr only use for closed issues label May 1, 2026
@max-charlamb

This comment was marked as outdated.

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 refactors CoreCLR exception state tracking to avoid eagerly synchronizing Thread::m_LastThrownObjectHandle (LTO) with ExInfo::m_exception during active exception dispatch, instead updating LTO lazily when ExInfo entries are popped. The intent is to reduce per-throw overhead after ExInfo::m_exception became the primary GC-tracked exception object during dispatch (per #127300).

Changes:

  • Update LTO lazily in ExInfo::PopExInfos from ExInfo::m_exception as each tracker is popped.
  • Remove eager-sync helpers/paths (SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState) and switch call sites to SafeSetLastThrownObject.
  • Restrict direct Thread::SetLastThrownObject usage by making it private and routing external callers through SafeSetLastThrownObject (now optionally marks unhandled).

Reviewed changes

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

Show a summary per file
FileDescription
src/coreclr/vm/threads.hUpdates LTO documentation, removes eager-sync APIs, makes SetLastThrownObject private, extends SafeSetLastThrownObject signature.
src/coreclr/vm/threads.cppRemoves SafeSetThrowables/SafeUpdateLastThrownObject implementations; updates thread teardown to clear LTO via SafeSetLastThrownObject.
src/coreclr/vm/exinfo.cppAdds lazy LTO update when popping ExInfo entries.
src/coreclr/vm/exceptionhandling.cppRemoves eager LTO sync at first-chance notification and removes SyncManagedExceptionState call after catch funclets.
src/coreclr/vm/excep.cppRemoves unhandled-exception filter resync of LTO against the active tracker.
src/coreclr/vm/eepolicy.cppRoutes fatal error/SO paths through SafeSetLastThrownObject(..., TRUE) instead of SetLastThrownObject / SafeSetThrowables.
src/coreclr/vm/comutilnative.cppUses SafeSetLastThrownObject in FailFast path.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
The reviewer noted that PopExInfos now reads m_exception (an OBJECTREF) and
calls Thread::SafeSetLastThrownObject, which requires MODE_COOPERATIVE for
non-NULL throwables, without an explicit GC mode contract.
All six callers were already cooperative:
- exceptionhandling.cpp:601 - explicit GCX_COOP() three lines above
- CleanUpForSecondPass callers (lines 2084/2139/2148) - the asm stub puts
the thread in COOP before entering, and the SO path uses GCX_COOP_NO_DTOR
- CallCatchFunclet (line 3185) - has MODE_COOPERATIVE CONTRACTL itself
- ResumeAtInterceptionLocation (line 3300) - catch-dispatch path also calls
PopExplicitFrames, which already requires cooperative mode
Add the contract explicitly so that future callers cannot violate it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Cleans up dead code identified by @jkotas in PR dotnet#127649 review:
- Delete Thread::SetLastThrownObjectHandle: zero callers post-PR.
- Delete SetManagedUnhandledExceptionBit forward declaration: had no
definition anywhere in the codebase.
- Remove the always-NULL pThrowableIn parameter from
NotifyAppDomainsOfUnhandledException.
- Replace UpdateCurrentThrowable (whose name was misleading: it never
updated anything, only returned a boolean) with an inline check at
the single call site using the GC-mode-agnostic IsThrowableNull /
IsLastThrownObjectNull helpers, removing the need for a GCX_COOP
inside the surrounding PAL_TRY block.
- Merge SafeSetLastThrownObject into SetLastThrownObject: a single
public method whose contract reflects the safe NOTHROW behavior.
The EX_TRY/EX_CATCH wraps only the throwing CreateHandle call;
observable behavior on the OOM fallback path is unchanged.
- Drop a stale `similar to UpdateCurrentThrowable()` comment in
eedbginterfaceimpl.cpp.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 20:13

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

Comment threadsrc/coreclr/vm/exinfo.cpp
Comment threadsrc/coreclr/vm/threads.h
Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
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>
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from 09de907 to 70188bbCompareMay 1, 2026 21:20
@max-charlamb
max-charlamb requested a review from jkotasMay 1, 2026 21:23
Comment threadsrc/coreclr/vm/excep.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
#endif // DEBUGGING_SUPPORTED

// Set LTO from the exception being destroyed so that post-ExInfo consumers

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.

We should do this only when it is actually going to be needed. For example, in a simple try { throw new Exception(); } catch { }, PopExInfos is going to be called from CallCatchFunclets, we create the LTO handle and the very next line in CallCatchFunclets is going to delete the LTO handle.

pThread->SetLastThrownObject(NULL);
}

// Sync managed exception state, for the managed thread, based upon any active exception tracker

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.

Is this going to cause a behavior change in things like the windbg !pe command ?

Before this change, !pe issued while the thread is executing a catch block is going to print the exception that's being caught. After this change, I think it is going to print nothing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(It would be nice if we can fix this without short-lived GCHandle allocation.)

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.

@jkotas, I am not sure I understand the comment about the !pe, this code is executed after the catch funclet has returned.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

It seems like I inadvertently broke this in the last PR, Juan has #127741 that fixes this by checking m_exception before LTO.

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.

Yes, this comment is attached to a wrong line.

I think it is still valid. In main, !pe is going to print the caught exception when the program is stopped inside a catch block in main, but not anymore with this change.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think #127741 should cover this. !pe reads DacpThreadData.lastThrownObjectHandle. That struct value is the following after Jaun's change:

// Prefer the active exception from ExInfo (pseudo-handle to m_exception field).// After the removal of SetThrowable/m_hThrowable, m_LastThrownObjectHandle is only// updated after exception dispatch completes, so during active dispatch it may be// stale. GetThrowableAsPseudoHandle returns the address of ExInfo::m_exception// which has the same dereference semantics as a real GC handle.
{
OBJECTHANDLE ohException = thread->GetThrowableAsPseudoHandle();
if (ohException == (OBJECTHANDLE)NULL)
{
ohException = thread->m_LastThrownObjectHandle;
}
threadData->lastThrownObjectHandle = TO_CDADDR(ohException);
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

There are some other cases where this logic needs to be applied. I will fix it more generally.

Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings May 4, 2026 16:51

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
{
pThread->SafeSetLastThrownObject(NULL);
pThread->SetLastThrownObject(NULL);
}
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@EgorBot -intel -amd

usingBenchmarkDotNet.Attributes;usingSystem.Runtime.CompilerServices;[MemoryDiagnoser]publicclassExceptionBench{[Benchmark]publicintSimpleThrowCatch(){try{thrownewInvalidOperationException("x");}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintRethrow(){try{try{thrownewInvalidOperationException("x");}catch{throw;}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintCatchAndThrowNew(){try{try{thrownewInvalidOperationException("inner");}catch{thrownewApplicationException("outer");}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintNestedThrowCatch(){intr=0;try{Outer();}catch(Exceptione){r=e.Message.Length;}returnr;}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidOuter(){try{Inner();}catch(InvalidOperationException){thrownewApplicationException("rewrapped");}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidInner()=>thrownewInvalidOperationException("x");[Benchmark]publicintDeepThrowCatch(){try{Recurse(20);return0;}catch(Exceptione){returne.Message.Length;}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidRecurse(intn){if(n==0)thrownewInvalidOperationException("deep");Recurse(n-1);}}

The interesting cases here:

BenchWhat it stresses
SimpleThrowCatchOne ExInfo lifetime — was 1 eager LTO write + 1 reset; now 1 write at PopExInfos
RethrowSafeUpdateLastThrownObject on rethrow — entirely removed
CatchAndThrowNewNew throw inside a catch — an extra eager LTO sync per dispatch transition
NestedThrowCatchMulti-frame transition: previously two eager writes, now one lazy write
DeepThrowCatchPure unwind cost (control: shouldn't change much)

Expecting the rethrow/catch-and-throw-new paths to show the largest delta since those are the dispatch transitions where SafeUpdateLastThrownObject / SafeSetThrowables used to run.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
pThread->GetExceptionState()->m_pCurrentTracker = pExInfo;

if (pExInfo == NULL && clearLtoIfChainEmpties)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I do not think the synchronization of the LTO should depend on whether there are any trackers left on the thread. The caller should either expects the Lto to be set or not, irrespective of what other exception handling may be present upstack.

The argument may want to be called setLtoToPoppedException

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

In the CallCatchFunclets case we can not set LTO inside of PopExinfos and unconditionally NULL it out afterwards. This should still prevent the extra creation in the case we care about.

CopilotAI review requested due to automatic review settings May 4, 2026 18:15
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from a163d98 to 7ac958aCompareMay 4, 2026 18: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 11 out of 11 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/vm/exceptionhandling.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlamb marked this pull request as draft May 4, 2026 22:00
Replace implicit `Thread::LastThrownObject` / `GetThrowable` /
`IsThrowableNull` family with source-explicit accessors that name the
view the caller wants:
- `Thread::GetThrowableHandle(ThrowableSource)`
- `Thread::GetThrowableRef(ThrowableSource)`
- `Thread::IsThrowableNull(ThrowableSource)`
`ThrowableSource` enum values: `ExInfoOnly`, `LTOOnly`, `ExInfoOrLTO`,
`LTOIfUnhandled`, `ExInfoOrLTOIfUnhandled`. Callers explicitly select
the view they want instead of relying on the (now lazy) LTO field
being coherent with the active ExInfo.
Migrated all 36 reader sites in CoreCLR (VM, EE, profiling, ETW,
prestub/interp, runtime EH, fatal/Watson, debugger DBI, DAC). Removed
seven legacy wrappers from `threads.h`: `GetThrowable`, `HasException`,
`GetThrowableAsPseudoHandle`, `IsThrowableNull()` no-arg,
`IsLastThrownObjectNull`, `LastThrownObject`, `LastThrownObjectHandle`,
plus `IsLastThrownObjectStackOverflowException` (one caller inlined).
Also fixes a Reflection.Invoke crash on the lazy branch:
`CallDescrWorkerUnwindFrameChainHandler`'s non-SO unwind path called
`CleanUpForSecondPass` -> `PopExInfos` from PREEMP, but the lazy
`PopExInfos` reads `OBJECTREF` and requires COOP. Wrapped with
`GCX_COOP()` in exceptionhandling.cpp.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 11, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-ExceptionHandling-coreclronly use for closed issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@jkotas@janvorli
, '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

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception - #127649

Closed
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2
Closed

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception#127649
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2

Conversation

@max-charlamb

@max-charlambmax-charlamb commented May 1, 2026

Copy link
Copy Markdown
Member

Note

This PR was authored with the assistance of GitHub Copilot.

Summary

Follow-up to #127300. Stop eagerly synchronizing Thread::m_LastThrownObjectHandle with ExInfo::m_exception during active exception dispatch. Instead, set LTO lazily when the ExInfo is destroyed in PopExInfos.

After #127300 made m_exception the GC-tracked exception object during dispatch, the eager LTO sync became redundant cost — every throw and dispatch transition was creating/updating a GCHandle that nothing on the dispatch path actually consumes (consumers read m_exception directly, or only need LTO once dispatch unwinds).

Key Changes

  • Lazy LTO update: ExInfo::~ExInfo (via PopExInfos) writes m_exception into m_LastThrownObjectHandle exactly once, when the exception leaves the dispatch chain. m_exception remains the sole source of truth during dispatch.
  • Remove SafeSetThrowables — the dual-write helper is no longer needed.
  • Remove SafeUpdateLastThrownObject — eager sync sites are gone.
  • Remove SyncManagedExceptionState and the InternalUnhandledExceptionFilter re-sync block.

Fold the redundant create-then-destroy in CallCatchFunclet

Previously, the simple try/throw/catch path did one wasteful GCHandle create + destroy per exception:

  1. PopExInfos would SetLastThrownObject(m_exception) per popped ExInfo (creating a fresh handle each iteration).
  2. CallCatchFunclet would immediately follow with if (!IsExceptionInProgress()) SetLastThrownObject(NULL) (destroying the handle just created).

PopExInfos now:

  • Tracks the bottommost popped m_exception in a local OBJECTREF and writes LTO once post-loop instead of per-iteration.
  • Accepts an optional clearLtoIfChainEmpties flag. When the chain becomes empty (no outer ExInfo), the catch-funclet caller passes true so the post-loop branch collapses into a single SetLastThrownObject(NULL) — itself a no-op when LTO was already NULL.

Net work for the common DispatchManagedException + simple managed catch case: zero GCHandle operations (vs one create + one destroy before). The bridging cases (search pass, second-pass unwind to native EX_CATCH, debugger interception) are unchanged: they still populate LTO with the bottommost popped m_exception so native EX_CATCH consumers can read it after the ExInfo is gone.

Cleanup of dead exception-handling helpers

While reviewing this PR, @jkotas pointed out additional dead code that the lazy-LTO change exposed, plus other latent dead code in the area:

  • Merge SafeSetLastThrownObject into SetLastThrownObject — single public method, NOTHROW contract. The EX_TRY/EX_CATCH now wraps only the throwing CreateHandle call; observable behavior on the OOM fallback path is unchanged.
  • Delete Thread::SetLastThrownObjectHandle — zero callers post-PR.
  • Delete SetManagedUnhandledExceptionBit forward declaration — no definition existed anywhere in the codebase.
  • Drop the always-NULL pThrowableIn parameter of NotifyAppDomainsOfUnhandledException.
  • Replace UpdateCurrentThrowable (whose name was misleading: it never updated anything, only returned a boolean) with an inline check at the single call site using the GC-mode-agnostic IsThrowableNull / IsLastThrownObjectNull helpers.
  • Delete CEEInfo::HandleException — unreachable since 2016 (4d9f4b8 "Remove SEH interactions between the JIT and the EE" 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. Removing it also retires a long-stale comment about "sync between the LTO and the exception tracker" that pre-dates the ExInfo redesign.

What stays unchanged

  • m_LastThrownObjectHandle itself remains an OBJECTHANDLE — required by the ICorDebug managed debugging protocol, which reads it cross-process via BuildFromGCHandle.
  • GetCurrentException / GetThreadException still prefer the live ExInfo::m_exception (via pseudo-handle) and only fall back to LTO when no ExInfo is active — same precedence as Remove ExInfo::m_hThrowable - use direct pointer for exception objects #127300.
  • Preallocated-exception handling (SO, OOM) is preserved.
  • The m_ltoIsUnhandled flag is kept — it is still set TRUE from EEPolicy::HandleFatalError / HandleFatalStackOverflow and consumed by the managed debugger via DAC and cDAC.

Related

Testing

  • Ran internal diagnostics tests with no regressions

…eagerly
Stop eagerly synchronizing m_LastThrownObjectHandle with ExInfo::m_exception
during active exception dispatch. Instead, set LTO lazily when the ExInfo is
destroyed in PopExInfos. This simplifies the code and makes m_exception the
sole source of truth during dispatch.
Removed: SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState,
and the InternalUnhandledExceptionFilter re-sync block.
Made SetLastThrownObject private; all external callers now use
SafeSetLastThrownObject which handles OOM gracefully.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 14:51
@github-actionsgithub-actionsBot added the area-ExceptionHandling-coreclr only use for closed issues label May 1, 2026
@max-charlamb

This comment was marked as outdated.

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 refactors CoreCLR exception state tracking to avoid eagerly synchronizing Thread::m_LastThrownObjectHandle (LTO) with ExInfo::m_exception during active exception dispatch, instead updating LTO lazily when ExInfo entries are popped. The intent is to reduce per-throw overhead after ExInfo::m_exception became the primary GC-tracked exception object during dispatch (per #127300).

Changes:

  • Update LTO lazily in ExInfo::PopExInfos from ExInfo::m_exception as each tracker is popped.
  • Remove eager-sync helpers/paths (SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState) and switch call sites to SafeSetLastThrownObject.
  • Restrict direct Thread::SetLastThrownObject usage by making it private and routing external callers through SafeSetLastThrownObject (now optionally marks unhandled).

Reviewed changes

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

Show a summary per file
FileDescription
src/coreclr/vm/threads.hUpdates LTO documentation, removes eager-sync APIs, makes SetLastThrownObject private, extends SafeSetLastThrownObject signature.
src/coreclr/vm/threads.cppRemoves SafeSetThrowables/SafeUpdateLastThrownObject implementations; updates thread teardown to clear LTO via SafeSetLastThrownObject.
src/coreclr/vm/exinfo.cppAdds lazy LTO update when popping ExInfo entries.
src/coreclr/vm/exceptionhandling.cppRemoves eager LTO sync at first-chance notification and removes SyncManagedExceptionState call after catch funclets.
src/coreclr/vm/excep.cppRemoves unhandled-exception filter resync of LTO against the active tracker.
src/coreclr/vm/eepolicy.cppRoutes fatal error/SO paths through SafeSetLastThrownObject(..., TRUE) instead of SetLastThrownObject / SafeSetThrowables.
src/coreclr/vm/comutilnative.cppUses SafeSetLastThrownObject in FailFast path.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
The reviewer noted that PopExInfos now reads m_exception (an OBJECTREF) and
calls Thread::SafeSetLastThrownObject, which requires MODE_COOPERATIVE for
non-NULL throwables, without an explicit GC mode contract.
All six callers were already cooperative:
- exceptionhandling.cpp:601 - explicit GCX_COOP() three lines above
- CleanUpForSecondPass callers (lines 2084/2139/2148) - the asm stub puts
the thread in COOP before entering, and the SO path uses GCX_COOP_NO_DTOR
- CallCatchFunclet (line 3185) - has MODE_COOPERATIVE CONTRACTL itself
- ResumeAtInterceptionLocation (line 3300) - catch-dispatch path also calls
PopExplicitFrames, which already requires cooperative mode
Add the contract explicitly so that future callers cannot violate it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Cleans up dead code identified by @jkotas in PR dotnet#127649 review:
- Delete Thread::SetLastThrownObjectHandle: zero callers post-PR.
- Delete SetManagedUnhandledExceptionBit forward declaration: had no
definition anywhere in the codebase.
- Remove the always-NULL pThrowableIn parameter from
NotifyAppDomainsOfUnhandledException.
- Replace UpdateCurrentThrowable (whose name was misleading: it never
updated anything, only returned a boolean) with an inline check at
the single call site using the GC-mode-agnostic IsThrowableNull /
IsLastThrownObjectNull helpers, removing the need for a GCX_COOP
inside the surrounding PAL_TRY block.
- Merge SafeSetLastThrownObject into SetLastThrownObject: a single
public method whose contract reflects the safe NOTHROW behavior.
The EX_TRY/EX_CATCH wraps only the throwing CreateHandle call;
observable behavior on the OOM fallback path is unchanged.
- Drop a stale `similar to UpdateCurrentThrowable()` comment in
eedbginterfaceimpl.cpp.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 20:13

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

Comment threadsrc/coreclr/vm/exinfo.cpp
Comment threadsrc/coreclr/vm/threads.h
Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
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>
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from 09de907 to 70188bbCompareMay 1, 2026 21:20
@max-charlamb
max-charlamb requested a review from jkotasMay 1, 2026 21:23
Comment threadsrc/coreclr/vm/excep.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
#endif // DEBUGGING_SUPPORTED

// Set LTO from the exception being destroyed so that post-ExInfo consumers

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.

We should do this only when it is actually going to be needed. For example, in a simple try { throw new Exception(); } catch { }, PopExInfos is going to be called from CallCatchFunclets, we create the LTO handle and the very next line in CallCatchFunclets is going to delete the LTO handle.

pThread->SetLastThrownObject(NULL);
}

// Sync managed exception state, for the managed thread, based upon any active exception tracker

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.

Is this going to cause a behavior change in things like the windbg !pe command ?

Before this change, !pe issued while the thread is executing a catch block is going to print the exception that's being caught. After this change, I think it is going to print nothing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(It would be nice if we can fix this without short-lived GCHandle allocation.)

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.

@jkotas, I am not sure I understand the comment about the !pe, this code is executed after the catch funclet has returned.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

It seems like I inadvertently broke this in the last PR, Juan has #127741 that fixes this by checking m_exception before LTO.

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.

Yes, this comment is attached to a wrong line.

I think it is still valid. In main, !pe is going to print the caught exception when the program is stopped inside a catch block in main, but not anymore with this change.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think #127741 should cover this. !pe reads DacpThreadData.lastThrownObjectHandle. That struct value is the following after Jaun's change:

// Prefer the active exception from ExInfo (pseudo-handle to m_exception field).// After the removal of SetThrowable/m_hThrowable, m_LastThrownObjectHandle is only// updated after exception dispatch completes, so during active dispatch it may be// stale. GetThrowableAsPseudoHandle returns the address of ExInfo::m_exception// which has the same dereference semantics as a real GC handle.
{
OBJECTHANDLE ohException = thread->GetThrowableAsPseudoHandle();
if (ohException == (OBJECTHANDLE)NULL)
{
ohException = thread->m_LastThrownObjectHandle;
}
threadData->lastThrownObjectHandle = TO_CDADDR(ohException);
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

There are some other cases where this logic needs to be applied. I will fix it more generally.

Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings May 4, 2026 16:51

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
{
pThread->SafeSetLastThrownObject(NULL);
pThread->SetLastThrownObject(NULL);
}
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@EgorBot -intel -amd

usingBenchmarkDotNet.Attributes;usingSystem.Runtime.CompilerServices;[MemoryDiagnoser]publicclassExceptionBench{[Benchmark]publicintSimpleThrowCatch(){try{thrownewInvalidOperationException("x");}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintRethrow(){try{try{thrownewInvalidOperationException("x");}catch{throw;}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintCatchAndThrowNew(){try{try{thrownewInvalidOperationException("inner");}catch{thrownewApplicationException("outer");}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintNestedThrowCatch(){intr=0;try{Outer();}catch(Exceptione){r=e.Message.Length;}returnr;}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidOuter(){try{Inner();}catch(InvalidOperationException){thrownewApplicationException("rewrapped");}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidInner()=>thrownewInvalidOperationException("x");[Benchmark]publicintDeepThrowCatch(){try{Recurse(20);return0;}catch(Exceptione){returne.Message.Length;}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidRecurse(intn){if(n==0)thrownewInvalidOperationException("deep");Recurse(n-1);}}

The interesting cases here:

BenchWhat it stresses
SimpleThrowCatchOne ExInfo lifetime — was 1 eager LTO write + 1 reset; now 1 write at PopExInfos
RethrowSafeUpdateLastThrownObject on rethrow — entirely removed
CatchAndThrowNewNew throw inside a catch — an extra eager LTO sync per dispatch transition
NestedThrowCatchMulti-frame transition: previously two eager writes, now one lazy write
DeepThrowCatchPure unwind cost (control: shouldn't change much)

Expecting the rethrow/catch-and-throw-new paths to show the largest delta since those are the dispatch transitions where SafeUpdateLastThrownObject / SafeSetThrowables used to run.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
pThread->GetExceptionState()->m_pCurrentTracker = pExInfo;

if (pExInfo == NULL && clearLtoIfChainEmpties)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I do not think the synchronization of the LTO should depend on whether there are any trackers left on the thread. The caller should either expects the Lto to be set or not, irrespective of what other exception handling may be present upstack.

The argument may want to be called setLtoToPoppedException

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

In the CallCatchFunclets case we can not set LTO inside of PopExinfos and unconditionally NULL it out afterwards. This should still prevent the extra creation in the case we care about.

CopilotAI review requested due to automatic review settings May 4, 2026 18:15
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from a163d98 to 7ac958aCompareMay 4, 2026 18: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 11 out of 11 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/vm/exceptionhandling.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlamb marked this pull request as draft May 4, 2026 22:00
Replace implicit `Thread::LastThrownObject` / `GetThrowable` /
`IsThrowableNull` family with source-explicit accessors that name the
view the caller wants:
- `Thread::GetThrowableHandle(ThrowableSource)`
- `Thread::GetThrowableRef(ThrowableSource)`
- `Thread::IsThrowableNull(ThrowableSource)`
`ThrowableSource` enum values: `ExInfoOnly`, `LTOOnly`, `ExInfoOrLTO`,
`LTOIfUnhandled`, `ExInfoOrLTOIfUnhandled`. Callers explicitly select
the view they want instead of relying on the (now lazy) LTO field
being coherent with the active ExInfo.
Migrated all 36 reader sites in CoreCLR (VM, EE, profiling, ETW,
prestub/interp, runtime EH, fatal/Watson, debugger DBI, DAC). Removed
seven legacy wrappers from `threads.h`: `GetThrowable`, `HasException`,
`GetThrowableAsPseudoHandle`, `IsThrowableNull()` no-arg,
`IsLastThrownObjectNull`, `LastThrownObject`, `LastThrownObjectHandle`,
plus `IsLastThrownObjectStackOverflowException` (one caller inlined).
Also fixes a Reflection.Invoke crash on the lazy branch:
`CallDescrWorkerUnwindFrameChainHandler`'s non-SO unwind path called
`CleanUpForSecondPass` -> `PopExInfos` from PREEMP, but the lazy
`PopExInfos` reads `OBJECTREF` and requires COOP. Wrapped with
`GCX_COOP()` in exceptionhandling.cpp.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 11, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-ExceptionHandling-coreclronly use for closed issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@jkotas@janvorli
, '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

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception - #127649

Closed
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2
Closed

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception#127649
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2

Conversation

@max-charlamb

@max-charlambmax-charlamb commented May 1, 2026

Copy link
Copy Markdown
Member

Note

This PR was authored with the assistance of GitHub Copilot.

Summary

Follow-up to #127300. Stop eagerly synchronizing Thread::m_LastThrownObjectHandle with ExInfo::m_exception during active exception dispatch. Instead, set LTO lazily when the ExInfo is destroyed in PopExInfos.

After #127300 made m_exception the GC-tracked exception object during dispatch, the eager LTO sync became redundant cost — every throw and dispatch transition was creating/updating a GCHandle that nothing on the dispatch path actually consumes (consumers read m_exception directly, or only need LTO once dispatch unwinds).

Key Changes

  • Lazy LTO update: ExInfo::~ExInfo (via PopExInfos) writes m_exception into m_LastThrownObjectHandle exactly once, when the exception leaves the dispatch chain. m_exception remains the sole source of truth during dispatch.
  • Remove SafeSetThrowables — the dual-write helper is no longer needed.
  • Remove SafeUpdateLastThrownObject — eager sync sites are gone.
  • Remove SyncManagedExceptionState and the InternalUnhandledExceptionFilter re-sync block.

Fold the redundant create-then-destroy in CallCatchFunclet

Previously, the simple try/throw/catch path did one wasteful GCHandle create + destroy per exception:

  1. PopExInfos would SetLastThrownObject(m_exception) per popped ExInfo (creating a fresh handle each iteration).
  2. CallCatchFunclet would immediately follow with if (!IsExceptionInProgress()) SetLastThrownObject(NULL) (destroying the handle just created).

PopExInfos now:

  • Tracks the bottommost popped m_exception in a local OBJECTREF and writes LTO once post-loop instead of per-iteration.
  • Accepts an optional clearLtoIfChainEmpties flag. When the chain becomes empty (no outer ExInfo), the catch-funclet caller passes true so the post-loop branch collapses into a single SetLastThrownObject(NULL) — itself a no-op when LTO was already NULL.

Net work for the common DispatchManagedException + simple managed catch case: zero GCHandle operations (vs one create + one destroy before). The bridging cases (search pass, second-pass unwind to native EX_CATCH, debugger interception) are unchanged: they still populate LTO with the bottommost popped m_exception so native EX_CATCH consumers can read it after the ExInfo is gone.

Cleanup of dead exception-handling helpers

While reviewing this PR, @jkotas pointed out additional dead code that the lazy-LTO change exposed, plus other latent dead code in the area:

  • Merge SafeSetLastThrownObject into SetLastThrownObject — single public method, NOTHROW contract. The EX_TRY/EX_CATCH now wraps only the throwing CreateHandle call; observable behavior on the OOM fallback path is unchanged.
  • Delete Thread::SetLastThrownObjectHandle — zero callers post-PR.
  • Delete SetManagedUnhandledExceptionBit forward declaration — no definition existed anywhere in the codebase.
  • Drop the always-NULL pThrowableIn parameter of NotifyAppDomainsOfUnhandledException.
  • Replace UpdateCurrentThrowable (whose name was misleading: it never updated anything, only returned a boolean) with an inline check at the single call site using the GC-mode-agnostic IsThrowableNull / IsLastThrownObjectNull helpers.
  • Delete CEEInfo::HandleException — unreachable since 2016 (4d9f4b8 "Remove SEH interactions between the JIT and the EE" 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. Removing it also retires a long-stale comment about "sync between the LTO and the exception tracker" that pre-dates the ExInfo redesign.

What stays unchanged

  • m_LastThrownObjectHandle itself remains an OBJECTHANDLE — required by the ICorDebug managed debugging protocol, which reads it cross-process via BuildFromGCHandle.
  • GetCurrentException / GetThreadException still prefer the live ExInfo::m_exception (via pseudo-handle) and only fall back to LTO when no ExInfo is active — same precedence as Remove ExInfo::m_hThrowable - use direct pointer for exception objects #127300.
  • Preallocated-exception handling (SO, OOM) is preserved.
  • The m_ltoIsUnhandled flag is kept — it is still set TRUE from EEPolicy::HandleFatalError / HandleFatalStackOverflow and consumed by the managed debugger via DAC and cDAC.

Related

Testing

  • Ran internal diagnostics tests with no regressions

…eagerly
Stop eagerly synchronizing m_LastThrownObjectHandle with ExInfo::m_exception
during active exception dispatch. Instead, set LTO lazily when the ExInfo is
destroyed in PopExInfos. This simplifies the code and makes m_exception the
sole source of truth during dispatch.
Removed: SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState,
and the InternalUnhandledExceptionFilter re-sync block.
Made SetLastThrownObject private; all external callers now use
SafeSetLastThrownObject which handles OOM gracefully.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 14:51
@github-actionsgithub-actionsBot added the area-ExceptionHandling-coreclr only use for closed issues label May 1, 2026
@max-charlamb

This comment was marked as outdated.

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 refactors CoreCLR exception state tracking to avoid eagerly synchronizing Thread::m_LastThrownObjectHandle (LTO) with ExInfo::m_exception during active exception dispatch, instead updating LTO lazily when ExInfo entries are popped. The intent is to reduce per-throw overhead after ExInfo::m_exception became the primary GC-tracked exception object during dispatch (per #127300).

Changes:

  • Update LTO lazily in ExInfo::PopExInfos from ExInfo::m_exception as each tracker is popped.
  • Remove eager-sync helpers/paths (SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState) and switch call sites to SafeSetLastThrownObject.
  • Restrict direct Thread::SetLastThrownObject usage by making it private and routing external callers through SafeSetLastThrownObject (now optionally marks unhandled).

Reviewed changes

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

Show a summary per file
FileDescription
src/coreclr/vm/threads.hUpdates LTO documentation, removes eager-sync APIs, makes SetLastThrownObject private, extends SafeSetLastThrownObject signature.
src/coreclr/vm/threads.cppRemoves SafeSetThrowables/SafeUpdateLastThrownObject implementations; updates thread teardown to clear LTO via SafeSetLastThrownObject.
src/coreclr/vm/exinfo.cppAdds lazy LTO update when popping ExInfo entries.
src/coreclr/vm/exceptionhandling.cppRemoves eager LTO sync at first-chance notification and removes SyncManagedExceptionState call after catch funclets.
src/coreclr/vm/excep.cppRemoves unhandled-exception filter resync of LTO against the active tracker.
src/coreclr/vm/eepolicy.cppRoutes fatal error/SO paths through SafeSetLastThrownObject(..., TRUE) instead of SetLastThrownObject / SafeSetThrowables.
src/coreclr/vm/comutilnative.cppUses SafeSetLastThrownObject in FailFast path.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
The reviewer noted that PopExInfos now reads m_exception (an OBJECTREF) and
calls Thread::SafeSetLastThrownObject, which requires MODE_COOPERATIVE for
non-NULL throwables, without an explicit GC mode contract.
All six callers were already cooperative:
- exceptionhandling.cpp:601 - explicit GCX_COOP() three lines above
- CleanUpForSecondPass callers (lines 2084/2139/2148) - the asm stub puts
the thread in COOP before entering, and the SO path uses GCX_COOP_NO_DTOR
- CallCatchFunclet (line 3185) - has MODE_COOPERATIVE CONTRACTL itself
- ResumeAtInterceptionLocation (line 3300) - catch-dispatch path also calls
PopExplicitFrames, which already requires cooperative mode
Add the contract explicitly so that future callers cannot violate it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Cleans up dead code identified by @jkotas in PR dotnet#127649 review:
- Delete Thread::SetLastThrownObjectHandle: zero callers post-PR.
- Delete SetManagedUnhandledExceptionBit forward declaration: had no
definition anywhere in the codebase.
- Remove the always-NULL pThrowableIn parameter from
NotifyAppDomainsOfUnhandledException.
- Replace UpdateCurrentThrowable (whose name was misleading: it never
updated anything, only returned a boolean) with an inline check at
the single call site using the GC-mode-agnostic IsThrowableNull /
IsLastThrownObjectNull helpers, removing the need for a GCX_COOP
inside the surrounding PAL_TRY block.
- Merge SafeSetLastThrownObject into SetLastThrownObject: a single
public method whose contract reflects the safe NOTHROW behavior.
The EX_TRY/EX_CATCH wraps only the throwing CreateHandle call;
observable behavior on the OOM fallback path is unchanged.
- Drop a stale `similar to UpdateCurrentThrowable()` comment in
eedbginterfaceimpl.cpp.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 20:13

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

Comment threadsrc/coreclr/vm/exinfo.cpp
Comment threadsrc/coreclr/vm/threads.h
Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
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>
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from 09de907 to 70188bbCompareMay 1, 2026 21:20
@max-charlamb
max-charlamb requested a review from jkotasMay 1, 2026 21:23
Comment threadsrc/coreclr/vm/excep.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
#endif // DEBUGGING_SUPPORTED

// Set LTO from the exception being destroyed so that post-ExInfo consumers

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.

We should do this only when it is actually going to be needed. For example, in a simple try { throw new Exception(); } catch { }, PopExInfos is going to be called from CallCatchFunclets, we create the LTO handle and the very next line in CallCatchFunclets is going to delete the LTO handle.

pThread->SetLastThrownObject(NULL);
}

// Sync managed exception state, for the managed thread, based upon any active exception tracker

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.

Is this going to cause a behavior change in things like the windbg !pe command ?

Before this change, !pe issued while the thread is executing a catch block is going to print the exception that's being caught. After this change, I think it is going to print nothing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(It would be nice if we can fix this without short-lived GCHandle allocation.)

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.

@jkotas, I am not sure I understand the comment about the !pe, this code is executed after the catch funclet has returned.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

It seems like I inadvertently broke this in the last PR, Juan has #127741 that fixes this by checking m_exception before LTO.

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.

Yes, this comment is attached to a wrong line.

I think it is still valid. In main, !pe is going to print the caught exception when the program is stopped inside a catch block in main, but not anymore with this change.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think #127741 should cover this. !pe reads DacpThreadData.lastThrownObjectHandle. That struct value is the following after Jaun's change:

// Prefer the active exception from ExInfo (pseudo-handle to m_exception field).// After the removal of SetThrowable/m_hThrowable, m_LastThrownObjectHandle is only// updated after exception dispatch completes, so during active dispatch it may be// stale. GetThrowableAsPseudoHandle returns the address of ExInfo::m_exception// which has the same dereference semantics as a real GC handle.
{
OBJECTHANDLE ohException = thread->GetThrowableAsPseudoHandle();
if (ohException == (OBJECTHANDLE)NULL)
{
ohException = thread->m_LastThrownObjectHandle;
}
threadData->lastThrownObjectHandle = TO_CDADDR(ohException);
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

There are some other cases where this logic needs to be applied. I will fix it more generally.

Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings May 4, 2026 16:51

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
{
pThread->SafeSetLastThrownObject(NULL);
pThread->SetLastThrownObject(NULL);
}
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@EgorBot -intel -amd

usingBenchmarkDotNet.Attributes;usingSystem.Runtime.CompilerServices;[MemoryDiagnoser]publicclassExceptionBench{[Benchmark]publicintSimpleThrowCatch(){try{thrownewInvalidOperationException("x");}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintRethrow(){try{try{thrownewInvalidOperationException("x");}catch{throw;}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintCatchAndThrowNew(){try{try{thrownewInvalidOperationException("inner");}catch{thrownewApplicationException("outer");}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintNestedThrowCatch(){intr=0;try{Outer();}catch(Exceptione){r=e.Message.Length;}returnr;}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidOuter(){try{Inner();}catch(InvalidOperationException){thrownewApplicationException("rewrapped");}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidInner()=>thrownewInvalidOperationException("x");[Benchmark]publicintDeepThrowCatch(){try{Recurse(20);return0;}catch(Exceptione){returne.Message.Length;}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidRecurse(intn){if(n==0)thrownewInvalidOperationException("deep");Recurse(n-1);}}

The interesting cases here:

BenchWhat it stresses
SimpleThrowCatchOne ExInfo lifetime — was 1 eager LTO write + 1 reset; now 1 write at PopExInfos
RethrowSafeUpdateLastThrownObject on rethrow — entirely removed
CatchAndThrowNewNew throw inside a catch — an extra eager LTO sync per dispatch transition
NestedThrowCatchMulti-frame transition: previously two eager writes, now one lazy write
DeepThrowCatchPure unwind cost (control: shouldn't change much)

Expecting the rethrow/catch-and-throw-new paths to show the largest delta since those are the dispatch transitions where SafeUpdateLastThrownObject / SafeSetThrowables used to run.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
pThread->GetExceptionState()->m_pCurrentTracker = pExInfo;

if (pExInfo == NULL && clearLtoIfChainEmpties)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I do not think the synchronization of the LTO should depend on whether there are any trackers left on the thread. The caller should either expects the Lto to be set or not, irrespective of what other exception handling may be present upstack.

The argument may want to be called setLtoToPoppedException

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

In the CallCatchFunclets case we can not set LTO inside of PopExinfos and unconditionally NULL it out afterwards. This should still prevent the extra creation in the case we care about.

CopilotAI review requested due to automatic review settings May 4, 2026 18:15
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from a163d98 to 7ac958aCompareMay 4, 2026 18: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 11 out of 11 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/vm/exceptionhandling.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlamb marked this pull request as draft May 4, 2026 22:00
Replace implicit `Thread::LastThrownObject` / `GetThrowable` /
`IsThrowableNull` family with source-explicit accessors that name the
view the caller wants:
- `Thread::GetThrowableHandle(ThrowableSource)`
- `Thread::GetThrowableRef(ThrowableSource)`
- `Thread::IsThrowableNull(ThrowableSource)`
`ThrowableSource` enum values: `ExInfoOnly`, `LTOOnly`, `ExInfoOrLTO`,
`LTOIfUnhandled`, `ExInfoOrLTOIfUnhandled`. Callers explicitly select
the view they want instead of relying on the (now lazy) LTO field
being coherent with the active ExInfo.
Migrated all 36 reader sites in CoreCLR (VM, EE, profiling, ETW,
prestub/interp, runtime EH, fatal/Watson, debugger DBI, DAC). Removed
seven legacy wrappers from `threads.h`: `GetThrowable`, `HasException`,
`GetThrowableAsPseudoHandle`, `IsThrowableNull()` no-arg,
`IsLastThrownObjectNull`, `LastThrownObject`, `LastThrownObjectHandle`,
plus `IsLastThrownObjectStackOverflowException` (one caller inlined).
Also fixes a Reflection.Invoke crash on the lazy branch:
`CallDescrWorkerUnwindFrameChainHandler`'s non-SO unwind path called
`CleanUpForSecondPass` -> `PopExInfos` from PREEMP, but the lazy
`PopExInfos` reads `OBJECTREF` and requires COOP. Wrapped with
`GCX_COOP()` in exceptionhandling.cpp.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 11, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-ExceptionHandling-coreclronly use for closed issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@jkotas@janvorli
, '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

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception - #127649

Closed
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2
Closed

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception#127649
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2

Conversation

@max-charlamb

@max-charlambmax-charlamb commented May 1, 2026

Copy link
Copy Markdown
Member

Note

This PR was authored with the assistance of GitHub Copilot.

Summary

Follow-up to #127300. Stop eagerly synchronizing Thread::m_LastThrownObjectHandle with ExInfo::m_exception during active exception dispatch. Instead, set LTO lazily when the ExInfo is destroyed in PopExInfos.

After #127300 made m_exception the GC-tracked exception object during dispatch, the eager LTO sync became redundant cost — every throw and dispatch transition was creating/updating a GCHandle that nothing on the dispatch path actually consumes (consumers read m_exception directly, or only need LTO once dispatch unwinds).

Key Changes

  • Lazy LTO update: ExInfo::~ExInfo (via PopExInfos) writes m_exception into m_LastThrownObjectHandle exactly once, when the exception leaves the dispatch chain. m_exception remains the sole source of truth during dispatch.
  • Remove SafeSetThrowables — the dual-write helper is no longer needed.
  • Remove SafeUpdateLastThrownObject — eager sync sites are gone.
  • Remove SyncManagedExceptionState and the InternalUnhandledExceptionFilter re-sync block.

Fold the redundant create-then-destroy in CallCatchFunclet

Previously, the simple try/throw/catch path did one wasteful GCHandle create + destroy per exception:

  1. PopExInfos would SetLastThrownObject(m_exception) per popped ExInfo (creating a fresh handle each iteration).
  2. CallCatchFunclet would immediately follow with if (!IsExceptionInProgress()) SetLastThrownObject(NULL) (destroying the handle just created).

PopExInfos now:

  • Tracks the bottommost popped m_exception in a local OBJECTREF and writes LTO once post-loop instead of per-iteration.
  • Accepts an optional clearLtoIfChainEmpties flag. When the chain becomes empty (no outer ExInfo), the catch-funclet caller passes true so the post-loop branch collapses into a single SetLastThrownObject(NULL) — itself a no-op when LTO was already NULL.

Net work for the common DispatchManagedException + simple managed catch case: zero GCHandle operations (vs one create + one destroy before). The bridging cases (search pass, second-pass unwind to native EX_CATCH, debugger interception) are unchanged: they still populate LTO with the bottommost popped m_exception so native EX_CATCH consumers can read it after the ExInfo is gone.

Cleanup of dead exception-handling helpers

While reviewing this PR, @jkotas pointed out additional dead code that the lazy-LTO change exposed, plus other latent dead code in the area:

  • Merge SafeSetLastThrownObject into SetLastThrownObject — single public method, NOTHROW contract. The EX_TRY/EX_CATCH now wraps only the throwing CreateHandle call; observable behavior on the OOM fallback path is unchanged.
  • Delete Thread::SetLastThrownObjectHandle — zero callers post-PR.
  • Delete SetManagedUnhandledExceptionBit forward declaration — no definition existed anywhere in the codebase.
  • Drop the always-NULL pThrowableIn parameter of NotifyAppDomainsOfUnhandledException.
  • Replace UpdateCurrentThrowable (whose name was misleading: it never updated anything, only returned a boolean) with an inline check at the single call site using the GC-mode-agnostic IsThrowableNull / IsLastThrownObjectNull helpers.
  • Delete CEEInfo::HandleException — unreachable since 2016 (4d9f4b8 "Remove SEH interactions between the JIT and the EE" 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. Removing it also retires a long-stale comment about "sync between the LTO and the exception tracker" that pre-dates the ExInfo redesign.

What stays unchanged

  • m_LastThrownObjectHandle itself remains an OBJECTHANDLE — required by the ICorDebug managed debugging protocol, which reads it cross-process via BuildFromGCHandle.
  • GetCurrentException / GetThreadException still prefer the live ExInfo::m_exception (via pseudo-handle) and only fall back to LTO when no ExInfo is active — same precedence as Remove ExInfo::m_hThrowable - use direct pointer for exception objects #127300.
  • Preallocated-exception handling (SO, OOM) is preserved.
  • The m_ltoIsUnhandled flag is kept — it is still set TRUE from EEPolicy::HandleFatalError / HandleFatalStackOverflow and consumed by the managed debugger via DAC and cDAC.

Related

Testing

  • Ran internal diagnostics tests with no regressions

…eagerly
Stop eagerly synchronizing m_LastThrownObjectHandle with ExInfo::m_exception
during active exception dispatch. Instead, set LTO lazily when the ExInfo is
destroyed in PopExInfos. This simplifies the code and makes m_exception the
sole source of truth during dispatch.
Removed: SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState,
and the InternalUnhandledExceptionFilter re-sync block.
Made SetLastThrownObject private; all external callers now use
SafeSetLastThrownObject which handles OOM gracefully.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 14:51
@github-actionsgithub-actionsBot added the area-ExceptionHandling-coreclr only use for closed issues label May 1, 2026
@max-charlamb

This comment was marked as outdated.

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 refactors CoreCLR exception state tracking to avoid eagerly synchronizing Thread::m_LastThrownObjectHandle (LTO) with ExInfo::m_exception during active exception dispatch, instead updating LTO lazily when ExInfo entries are popped. The intent is to reduce per-throw overhead after ExInfo::m_exception became the primary GC-tracked exception object during dispatch (per #127300).

Changes:

  • Update LTO lazily in ExInfo::PopExInfos from ExInfo::m_exception as each tracker is popped.
  • Remove eager-sync helpers/paths (SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState) and switch call sites to SafeSetLastThrownObject.
  • Restrict direct Thread::SetLastThrownObject usage by making it private and routing external callers through SafeSetLastThrownObject (now optionally marks unhandled).

Reviewed changes

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

Show a summary per file
FileDescription
src/coreclr/vm/threads.hUpdates LTO documentation, removes eager-sync APIs, makes SetLastThrownObject private, extends SafeSetLastThrownObject signature.
src/coreclr/vm/threads.cppRemoves SafeSetThrowables/SafeUpdateLastThrownObject implementations; updates thread teardown to clear LTO via SafeSetLastThrownObject.
src/coreclr/vm/exinfo.cppAdds lazy LTO update when popping ExInfo entries.
src/coreclr/vm/exceptionhandling.cppRemoves eager LTO sync at first-chance notification and removes SyncManagedExceptionState call after catch funclets.
src/coreclr/vm/excep.cppRemoves unhandled-exception filter resync of LTO against the active tracker.
src/coreclr/vm/eepolicy.cppRoutes fatal error/SO paths through SafeSetLastThrownObject(..., TRUE) instead of SetLastThrownObject / SafeSetThrowables.
src/coreclr/vm/comutilnative.cppUses SafeSetLastThrownObject in FailFast path.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
The reviewer noted that PopExInfos now reads m_exception (an OBJECTREF) and
calls Thread::SafeSetLastThrownObject, which requires MODE_COOPERATIVE for
non-NULL throwables, without an explicit GC mode contract.
All six callers were already cooperative:
- exceptionhandling.cpp:601 - explicit GCX_COOP() three lines above
- CleanUpForSecondPass callers (lines 2084/2139/2148) - the asm stub puts
the thread in COOP before entering, and the SO path uses GCX_COOP_NO_DTOR
- CallCatchFunclet (line 3185) - has MODE_COOPERATIVE CONTRACTL itself
- ResumeAtInterceptionLocation (line 3300) - catch-dispatch path also calls
PopExplicitFrames, which already requires cooperative mode
Add the contract explicitly so that future callers cannot violate it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Cleans up dead code identified by @jkotas in PR dotnet#127649 review:
- Delete Thread::SetLastThrownObjectHandle: zero callers post-PR.
- Delete SetManagedUnhandledExceptionBit forward declaration: had no
definition anywhere in the codebase.
- Remove the always-NULL pThrowableIn parameter from
NotifyAppDomainsOfUnhandledException.
- Replace UpdateCurrentThrowable (whose name was misleading: it never
updated anything, only returned a boolean) with an inline check at
the single call site using the GC-mode-agnostic IsThrowableNull /
IsLastThrownObjectNull helpers, removing the need for a GCX_COOP
inside the surrounding PAL_TRY block.
- Merge SafeSetLastThrownObject into SetLastThrownObject: a single
public method whose contract reflects the safe NOTHROW behavior.
The EX_TRY/EX_CATCH wraps only the throwing CreateHandle call;
observable behavior on the OOM fallback path is unchanged.
- Drop a stale `similar to UpdateCurrentThrowable()` comment in
eedbginterfaceimpl.cpp.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 20:13

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

Comment threadsrc/coreclr/vm/exinfo.cpp
Comment threadsrc/coreclr/vm/threads.h
Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
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>
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from 09de907 to 70188bbCompareMay 1, 2026 21:20
@max-charlamb
max-charlamb requested a review from jkotasMay 1, 2026 21:23
Comment threadsrc/coreclr/vm/excep.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
#endif // DEBUGGING_SUPPORTED

// Set LTO from the exception being destroyed so that post-ExInfo consumers

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.

We should do this only when it is actually going to be needed. For example, in a simple try { throw new Exception(); } catch { }, PopExInfos is going to be called from CallCatchFunclets, we create the LTO handle and the very next line in CallCatchFunclets is going to delete the LTO handle.

pThread->SetLastThrownObject(NULL);
}

// Sync managed exception state, for the managed thread, based upon any active exception tracker

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.

Is this going to cause a behavior change in things like the windbg !pe command ?

Before this change, !pe issued while the thread is executing a catch block is going to print the exception that's being caught. After this change, I think it is going to print nothing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(It would be nice if we can fix this without short-lived GCHandle allocation.)

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.

@jkotas, I am not sure I understand the comment about the !pe, this code is executed after the catch funclet has returned.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

It seems like I inadvertently broke this in the last PR, Juan has #127741 that fixes this by checking m_exception before LTO.

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.

Yes, this comment is attached to a wrong line.

I think it is still valid. In main, !pe is going to print the caught exception when the program is stopped inside a catch block in main, but not anymore with this change.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think #127741 should cover this. !pe reads DacpThreadData.lastThrownObjectHandle. That struct value is the following after Jaun's change:

// Prefer the active exception from ExInfo (pseudo-handle to m_exception field).// After the removal of SetThrowable/m_hThrowable, m_LastThrownObjectHandle is only// updated after exception dispatch completes, so during active dispatch it may be// stale. GetThrowableAsPseudoHandle returns the address of ExInfo::m_exception// which has the same dereference semantics as a real GC handle.
{
OBJECTHANDLE ohException = thread->GetThrowableAsPseudoHandle();
if (ohException == (OBJECTHANDLE)NULL)
{
ohException = thread->m_LastThrownObjectHandle;
}
threadData->lastThrownObjectHandle = TO_CDADDR(ohException);
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

There are some other cases where this logic needs to be applied. I will fix it more generally.

Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings May 4, 2026 16:51

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
{
pThread->SafeSetLastThrownObject(NULL);
pThread->SetLastThrownObject(NULL);
}
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@EgorBot -intel -amd

usingBenchmarkDotNet.Attributes;usingSystem.Runtime.CompilerServices;[MemoryDiagnoser]publicclassExceptionBench{[Benchmark]publicintSimpleThrowCatch(){try{thrownewInvalidOperationException("x");}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintRethrow(){try{try{thrownewInvalidOperationException("x");}catch{throw;}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintCatchAndThrowNew(){try{try{thrownewInvalidOperationException("inner");}catch{thrownewApplicationException("outer");}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintNestedThrowCatch(){intr=0;try{Outer();}catch(Exceptione){r=e.Message.Length;}returnr;}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidOuter(){try{Inner();}catch(InvalidOperationException){thrownewApplicationException("rewrapped");}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidInner()=>thrownewInvalidOperationException("x");[Benchmark]publicintDeepThrowCatch(){try{Recurse(20);return0;}catch(Exceptione){returne.Message.Length;}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidRecurse(intn){if(n==0)thrownewInvalidOperationException("deep");Recurse(n-1);}}

The interesting cases here:

BenchWhat it stresses
SimpleThrowCatchOne ExInfo lifetime — was 1 eager LTO write + 1 reset; now 1 write at PopExInfos
RethrowSafeUpdateLastThrownObject on rethrow — entirely removed
CatchAndThrowNewNew throw inside a catch — an extra eager LTO sync per dispatch transition
NestedThrowCatchMulti-frame transition: previously two eager writes, now one lazy write
DeepThrowCatchPure unwind cost (control: shouldn't change much)

Expecting the rethrow/catch-and-throw-new paths to show the largest delta since those are the dispatch transitions where SafeUpdateLastThrownObject / SafeSetThrowables used to run.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
pThread->GetExceptionState()->m_pCurrentTracker = pExInfo;

if (pExInfo == NULL && clearLtoIfChainEmpties)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I do not think the synchronization of the LTO should depend on whether there are any trackers left on the thread. The caller should either expects the Lto to be set or not, irrespective of what other exception handling may be present upstack.

The argument may want to be called setLtoToPoppedException

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

In the CallCatchFunclets case we can not set LTO inside of PopExinfos and unconditionally NULL it out afterwards. This should still prevent the extra creation in the case we care about.

CopilotAI review requested due to automatic review settings May 4, 2026 18:15
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from a163d98 to 7ac958aCompareMay 4, 2026 18: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 11 out of 11 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/vm/exceptionhandling.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlamb marked this pull request as draft May 4, 2026 22:00
Replace implicit `Thread::LastThrownObject` / `GetThrowable` /
`IsThrowableNull` family with source-explicit accessors that name the
view the caller wants:
- `Thread::GetThrowableHandle(ThrowableSource)`
- `Thread::GetThrowableRef(ThrowableSource)`
- `Thread::IsThrowableNull(ThrowableSource)`
`ThrowableSource` enum values: `ExInfoOnly`, `LTOOnly`, `ExInfoOrLTO`,
`LTOIfUnhandled`, `ExInfoOrLTOIfUnhandled`. Callers explicitly select
the view they want instead of relying on the (now lazy) LTO field
being coherent with the active ExInfo.
Migrated all 36 reader sites in CoreCLR (VM, EE, profiling, ETW,
prestub/interp, runtime EH, fatal/Watson, debugger DBI, DAC). Removed
seven legacy wrappers from `threads.h`: `GetThrowable`, `HasException`,
`GetThrowableAsPseudoHandle`, `IsThrowableNull()` no-arg,
`IsLastThrownObjectNull`, `LastThrownObject`, `LastThrownObjectHandle`,
plus `IsLastThrownObjectStackOverflowException` (one caller inlined).
Also fixes a Reflection.Invoke crash on the lazy branch:
`CallDescrWorkerUnwindFrameChainHandler`'s non-SO unwind path called
`CleanUpForSecondPass` -> `PopExInfos` from PREEMP, but the lazy
`PopExInfos` reads `OBJECTREF` and requires COOP. Wrapped with
`GCX_COOP()` in exceptionhandling.cpp.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 11, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-ExceptionHandling-coreclronly use for closed issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@jkotas@janvorli
, '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

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception - #127649

Closed
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2
Closed

Lazy sync of Thread::m_LastThrownObjectHandle from ExInfo::m_exception#127649
max-charlamb wants to merge 7 commits into
dotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2

Conversation

@max-charlamb

@max-charlambmax-charlamb commented May 1, 2026

Copy link
Copy Markdown
Member

Note

This PR was authored with the assistance of GitHub Copilot.

Summary

Follow-up to #127300. Stop eagerly synchronizing Thread::m_LastThrownObjectHandle with ExInfo::m_exception during active exception dispatch. Instead, set LTO lazily when the ExInfo is destroyed in PopExInfos.

After #127300 made m_exception the GC-tracked exception object during dispatch, the eager LTO sync became redundant cost — every throw and dispatch transition was creating/updating a GCHandle that nothing on the dispatch path actually consumes (consumers read m_exception directly, or only need LTO once dispatch unwinds).

Key Changes

  • Lazy LTO update: ExInfo::~ExInfo (via PopExInfos) writes m_exception into m_LastThrownObjectHandle exactly once, when the exception leaves the dispatch chain. m_exception remains the sole source of truth during dispatch.
  • Remove SafeSetThrowables — the dual-write helper is no longer needed.
  • Remove SafeUpdateLastThrownObject — eager sync sites are gone.
  • Remove SyncManagedExceptionState and the InternalUnhandledExceptionFilter re-sync block.

Fold the redundant create-then-destroy in CallCatchFunclet

Previously, the simple try/throw/catch path did one wasteful GCHandle create + destroy per exception:

  1. PopExInfos would SetLastThrownObject(m_exception) per popped ExInfo (creating a fresh handle each iteration).
  2. CallCatchFunclet would immediately follow with if (!IsExceptionInProgress()) SetLastThrownObject(NULL) (destroying the handle just created).

PopExInfos now:

  • Tracks the bottommost popped m_exception in a local OBJECTREF and writes LTO once post-loop instead of per-iteration.
  • Accepts an optional clearLtoIfChainEmpties flag. When the chain becomes empty (no outer ExInfo), the catch-funclet caller passes true so the post-loop branch collapses into a single SetLastThrownObject(NULL) — itself a no-op when LTO was already NULL.

Net work for the common DispatchManagedException + simple managed catch case: zero GCHandle operations (vs one create + one destroy before). The bridging cases (search pass, second-pass unwind to native EX_CATCH, debugger interception) are unchanged: they still populate LTO with the bottommost popped m_exception so native EX_CATCH consumers can read it after the ExInfo is gone.

Cleanup of dead exception-handling helpers

While reviewing this PR, @jkotas pointed out additional dead code that the lazy-LTO change exposed, plus other latent dead code in the area:

  • Merge SafeSetLastThrownObject into SetLastThrownObject — single public method, NOTHROW contract. The EX_TRY/EX_CATCH now wraps only the throwing CreateHandle call; observable behavior on the OOM fallback path is unchanged.
  • Delete Thread::SetLastThrownObjectHandle — zero callers post-PR.
  • Delete SetManagedUnhandledExceptionBit forward declaration — no definition existed anywhere in the codebase.
  • Drop the always-NULL pThrowableIn parameter of NotifyAppDomainsOfUnhandledException.
  • Replace UpdateCurrentThrowable (whose name was misleading: it never updated anything, only returned a boolean) with an inline check at the single call site using the GC-mode-agnostic IsThrowableNull / IsLastThrownObjectNull helpers.
  • Delete CEEInfo::HandleException — unreachable since 2016 (4d9f4b8 "Remove SEH interactions between the JIT and the EE" 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. Removing it also retires a long-stale comment about "sync between the LTO and the exception tracker" that pre-dates the ExInfo redesign.

What stays unchanged

  • m_LastThrownObjectHandle itself remains an OBJECTHANDLE — required by the ICorDebug managed debugging protocol, which reads it cross-process via BuildFromGCHandle.
  • GetCurrentException / GetThreadException still prefer the live ExInfo::m_exception (via pseudo-handle) and only fall back to LTO when no ExInfo is active — same precedence as Remove ExInfo::m_hThrowable - use direct pointer for exception objects #127300.
  • Preallocated-exception handling (SO, OOM) is preserved.
  • The m_ltoIsUnhandled flag is kept — it is still set TRUE from EEPolicy::HandleFatalError / HandleFatalStackOverflow and consumed by the managed debugger via DAC and cDAC.

Related

Testing

  • Ran internal diagnostics tests with no regressions

…eagerly
Stop eagerly synchronizing m_LastThrownObjectHandle with ExInfo::m_exception
during active exception dispatch. Instead, set LTO lazily when the ExInfo is
destroyed in PopExInfos. This simplifies the code and makes m_exception the
sole source of truth during dispatch.
Removed: SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState,
and the InternalUnhandledExceptionFilter re-sync block.
Made SetLastThrownObject private; all external callers now use
SafeSetLastThrownObject which handles OOM gracefully.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 14:51
@github-actionsgithub-actionsBot added the area-ExceptionHandling-coreclr only use for closed issues label May 1, 2026
@max-charlamb

This comment was marked as outdated.

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 refactors CoreCLR exception state tracking to avoid eagerly synchronizing Thread::m_LastThrownObjectHandle (LTO) with ExInfo::m_exception during active exception dispatch, instead updating LTO lazily when ExInfo entries are popped. The intent is to reduce per-throw overhead after ExInfo::m_exception became the primary GC-tracked exception object during dispatch (per #127300).

Changes:

  • Update LTO lazily in ExInfo::PopExInfos from ExInfo::m_exception as each tracker is popped.
  • Remove eager-sync helpers/paths (SafeSetThrowables, SafeUpdateLastThrownObject, SyncManagedExceptionState) and switch call sites to SafeSetLastThrownObject.
  • Restrict direct Thread::SetLastThrownObject usage by making it private and routing external callers through SafeSetLastThrownObject (now optionally marks unhandled).

Reviewed changes

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

Show a summary per file
FileDescription
src/coreclr/vm/threads.hUpdates LTO documentation, removes eager-sync APIs, makes SetLastThrownObject private, extends SafeSetLastThrownObject signature.
src/coreclr/vm/threads.cppRemoves SafeSetThrowables/SafeUpdateLastThrownObject implementations; updates thread teardown to clear LTO via SafeSetLastThrownObject.
src/coreclr/vm/exinfo.cppAdds lazy LTO update when popping ExInfo entries.
src/coreclr/vm/exceptionhandling.cppRemoves eager LTO sync at first-chance notification and removes SyncManagedExceptionState call after catch funclets.
src/coreclr/vm/excep.cppRemoves unhandled-exception filter resync of LTO against the active tracker.
src/coreclr/vm/eepolicy.cppRoutes fatal error/SO paths through SafeSetLastThrownObject(..., TRUE) instead of SetLastThrownObject / SafeSetThrowables.
src/coreclr/vm/comutilnative.cppUses SafeSetLastThrownObject in FailFast path.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
The reviewer noted that PopExInfos now reads m_exception (an OBJECTREF) and
calls Thread::SafeSetLastThrownObject, which requires MODE_COOPERATIVE for
non-NULL throwables, without an explicit GC mode contract.
All six callers were already cooperative:
- exceptionhandling.cpp:601 - explicit GCX_COOP() three lines above
- CleanUpForSecondPass callers (lines 2084/2139/2148) - the asm stub puts
the thread in COOP before entering, and the SO path uses GCX_COOP_NO_DTOR
- CallCatchFunclet (line 3185) - has MODE_COOPERATIVE CONTRACTL itself
- ResumeAtInterceptionLocation (line 3300) - catch-dispatch path also calls
PopExplicitFrames, which already requires cooperative mode
Add the contract explicitly so that future callers cannot violate it.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/threads.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Comment threadsrc/coreclr/vm/excep.cpp Outdated
Cleans up dead code identified by @jkotas in PR dotnet#127649 review:
- Delete Thread::SetLastThrownObjectHandle: zero callers post-PR.
- Delete SetManagedUnhandledExceptionBit forward declaration: had no
definition anywhere in the codebase.
- Remove the always-NULL pThrowableIn parameter from
NotifyAppDomainsOfUnhandledException.
- Replace UpdateCurrentThrowable (whose name was misleading: it never
updated anything, only returned a boolean) with an inline check at
the single call site using the GC-mode-agnostic IsThrowableNull /
IsLastThrownObjectNull helpers, removing the need for a GCX_COOP
inside the surrounding PAL_TRY block.
- Merge SafeSetLastThrownObject into SetLastThrownObject: a single
public method whose contract reflects the safe NOTHROW behavior.
The EX_TRY/EX_CATCH wraps only the throwing CreateHandle call;
observable behavior on the OOM fallback path is unchanged.
- Drop a stale `similar to UpdateCurrentThrowable()` comment in
eedbginterfaceimpl.cpp.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings May 1, 2026 20:13

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

Comment threadsrc/coreclr/vm/exinfo.cpp
Comment threadsrc/coreclr/vm/threads.h
Comment threadsrc/coreclr/vm/jitinterface.cpp Outdated
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>
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from 09de907 to 70188bbCompareMay 1, 2026 21:20
@max-charlamb
max-charlamb requested a review from jkotasMay 1, 2026 21:23
Comment threadsrc/coreclr/vm/excep.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/threads.h Outdated
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
#endif // DEBUGGING_SUPPORTED

// Set LTO from the exception being destroyed so that post-ExInfo consumers

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.

We should do this only when it is actually going to be needed. For example, in a simple try { throw new Exception(); } catch { }, PopExInfos is going to be called from CallCatchFunclets, we create the LTO handle and the very next line in CallCatchFunclets is going to delete the LTO handle.

pThread->SetLastThrownObject(NULL);
}

// Sync managed exception state, for the managed thread, based upon any active exception tracker

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.

Is this going to cause a behavior change in things like the windbg !pe command ?

Before this change, !pe issued while the thread is executing a catch block is going to print the exception that's being caught. After this change, I think it is going to print nothing.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(It would be nice if we can fix this without short-lived GCHandle allocation.)

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.

@jkotas, I am not sure I understand the comment about the !pe, this code is executed after the catch funclet has returned.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

It seems like I inadvertently broke this in the last PR, Juan has #127741 that fixes this by checking m_exception before LTO.

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.

Yes, this comment is attached to a wrong line.

I think it is still valid. In main, !pe is going to print the caught exception when the program is stopped inside a catch block in main, but not anymore with this change.

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

I think #127741 should cover this. !pe reads DacpThreadData.lastThrownObjectHandle. That struct value is the following after Jaun's change:

// Prefer the active exception from ExInfo (pseudo-handle to m_exception field).// After the removal of SetThrowable/m_hThrowable, m_LastThrownObjectHandle is only// updated after exception dispatch completes, so during active dispatch it may be// stale. GetThrowableAsPseudoHandle returns the address of ExInfo::m_exception// which has the same dereference semantics as a real GC handle.
{
OBJECTHANDLE ohException = thread->GetThrowableAsPseudoHandle();
if (ohException == (OBJECTHANDLE)NULL)
{
ohException = thread->m_LastThrownObjectHandle;
}
threadData->lastThrownObjectHandle = TO_CDADDR(ohException);
}

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

There are some other cases where this logic needs to be applied. I will fix it more generally.

Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings May 4, 2026 16:51

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

Comment threadsrc/coreclr/vm/exceptionhandling.cpp Outdated
{
pThread->SafeSetLastThrownObject(NULL);
pThread->SetLastThrownObject(NULL);
}
Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
@max-charlamb

Copy link
Copy Markdown
MemberAuthor

@EgorBot -intel -amd

usingBenchmarkDotNet.Attributes;usingSystem.Runtime.CompilerServices;[MemoryDiagnoser]publicclassExceptionBench{[Benchmark]publicintSimpleThrowCatch(){try{thrownewInvalidOperationException("x");}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintRethrow(){try{try{thrownewInvalidOperationException("x");}catch{throw;}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintCatchAndThrowNew(){try{try{thrownewInvalidOperationException("inner");}catch{thrownewApplicationException("outer");}}catch(Exceptione){returne.Message.Length;}}[Benchmark]publicintNestedThrowCatch(){intr=0;try{Outer();}catch(Exceptione){r=e.Message.Length;}returnr;}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidOuter(){try{Inner();}catch(InvalidOperationException){thrownewApplicationException("rewrapped");}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidInner()=>thrownewInvalidOperationException("x");[Benchmark]publicintDeepThrowCatch(){try{Recurse(20);return0;}catch(Exceptione){returne.Message.Length;}}[MethodImpl(MethodImplOptions.NoInlining)]staticvoidRecurse(intn){if(n==0)thrownewInvalidOperationException("deep");Recurse(n-1);}}

The interesting cases here:

BenchWhat it stresses
SimpleThrowCatchOne ExInfo lifetime — was 1 eager LTO write + 1 reset; now 1 write at PopExInfos
RethrowSafeUpdateLastThrownObject on rethrow — entirely removed
CatchAndThrowNewNew throw inside a catch — an extra eager LTO sync per dispatch transition
NestedThrowCatchMulti-frame transition: previously two eager writes, now one lazy write
DeepThrowCatchPure unwind cost (control: shouldn't change much)

Expecting the rethrow/catch-and-throw-new paths to show the largest delta since those are the dispatch transitions where SafeUpdateLastThrownObject / SafeSetThrowables used to run.

Comment threadsrc/coreclr/vm/exinfo.cpp Outdated
}
pThread->GetExceptionState()->m_pCurrentTracker = pExInfo;

if (pExInfo == NULL && clearLtoIfChainEmpties)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I do not think the synchronization of the LTO should depend on whether there are any trackers left on the thread. The caller should either expects the Lto to be set or not, irrespective of what other exception handling may be present upstack.

The argument may want to be called setLtoToPoppedException

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

In the CallCatchFunclets case we can not set LTO inside of PopExinfos and unconditionally NULL it out afterwards. This should still prevent the extra creation in the case we care about.

CopilotAI review requested due to automatic review settings May 4, 2026 18:15
@max-charlamb
max-charlambforce-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from a163d98 to 7ac958aCompareMay 4, 2026 18: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 11 out of 11 changed files in this pull request and generated 2 comments.

Comment threadsrc/coreclr/vm/exceptionhandling.cpp
Comment threadsrc/coreclr/vm/threads.h Outdated
@max-charlamb
max-charlamb marked this pull request as draft May 4, 2026 22:00
Replace implicit `Thread::LastThrownObject` / `GetThrowable` /
`IsThrowableNull` family with source-explicit accessors that name the
view the caller wants:
- `Thread::GetThrowableHandle(ThrowableSource)`
- `Thread::GetThrowableRef(ThrowableSource)`
- `Thread::IsThrowableNull(ThrowableSource)`
`ThrowableSource` enum values: `ExInfoOnly`, `LTOOnly`, `ExInfoOrLTO`,
`LTOIfUnhandled`, `ExInfoOrLTOIfUnhandled`. Callers explicitly select
the view they want instead of relying on the (now lazy) LTO field
being coherent with the active ExInfo.
Migrated all 36 reader sites in CoreCLR (VM, EE, profiling, ETW,
prestub/interp, runtime EH, fatal/Watson, debugger DBI, DAC). Removed
seven legacy wrappers from `threads.h`: `GetThrowable`, `HasException`,
`GetThrowableAsPseudoHandle`, `IsThrowableNull()` no-arg,
`IsLastThrownObjectNull`, `LastThrownObject`, `LastThrownObjectHandle`,
plus `IsLastThrownObjectStackOverflowException` (one caller inlined).
Also fixes a Reflection.Invoke crash on the lazy branch:
`CallDescrWorkerUnwindFrameChainHandler`'s non-SO unwind path called
`CleanUpForSecondPass` -> `PopExInfos` from PREEMP, but the lazy
`PopExInfos` reads `OBJECTREF` and requires COOP. Wrapped with
`GCX_COOP()` in exceptionhandling.cpp.
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Jun 11, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

area-ExceptionHandling-coreclronly use for closed issues

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants

@max-charlamb@jkotas@janvorli