Skip to content

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

Draft
max-charlamb wants to merge 7 commits intodotnet:mainfrom
max-charlamb:dev/max-charlamb/lazy-lto-sync-v2
Draft

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

Conversation

@max-charlamb
Copy link
Copy Markdown
Member

@max-charlamb max-charlamb commented May 1, 2026

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>
@max-charlamb

This comment was marked as outdated.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR 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
File Description
src/coreclr/vm/threads.h Updates LTO documentation, removes eager-sync APIs, makes SetLastThrownObject private, extends SafeSetLastThrownObject signature.
src/coreclr/vm/threads.cpp Removes SafeSetThrowables/SafeUpdateLastThrownObject implementations; updates thread teardown to clear LTO via SafeSetLastThrownObject.
src/coreclr/vm/exinfo.cpp Adds lazy LTO update when popping ExInfo entries.
src/coreclr/vm/exceptionhandling.cpp Removes eager LTO sync at first-chance notification and removes SyncManagedExceptionState call after catch funclets.
src/coreclr/vm/excep.cpp Removes unhandled-exception filter resync of LTO against the active tracker.
src/coreclr/vm/eepolicy.cpp Routes fatal error/SO paths through SafeSetLastThrownObject(..., TRUE) instead of SetLastThrownObject / SafeSetThrowables.
src/coreclr/vm/comutilnative.cpp Uses SafeSetLastThrownObject in FailFast path.

Comment thread src/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 thread src/coreclr/vm/threads.cpp Outdated
Comment thread src/coreclr/vm/threads.cpp Outdated
Comment thread src/coreclr/vm/excep.cpp Outdated
Comment thread src/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>
Copilot AI review requested due to automatic review settings May 1, 2026 20:13
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread src/coreclr/vm/exinfo.cpp
Comment thread src/coreclr/vm/threads.h
Comment thread src/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-charlamb force-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from 09de907 to 70188bb Compare May 1, 2026 21:20
@max-charlamb max-charlamb requested a review from jkotas May 1, 2026 21:23
Comment thread src/coreclr/vm/excep.cpp
Comment thread src/coreclr/vm/threads.h Outdated
Comment thread src/coreclr/vm/threads.h Outdated
Comment thread src/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
Member Author

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
Member Author

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
Member Author

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>
Copilot AI review requested due to automatic review settings May 4, 2026 16:51
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread src/coreclr/vm/exceptionhandling.cpp Outdated
{
pThread->SafeSetLastThrownObject(NULL);
pThread->SetLastThrownObject(NULL);
}
Comment thread src/coreclr/vm/exinfo.cpp Outdated
@max-charlamb
Copy link
Copy Markdown
Member Author

@EgorBot -intel -amd

using BenchmarkDotNet.Attributes;
using System.Runtime.CompilerServices;

[MemoryDiagnoser]
public class ExceptionBench
{
    [Benchmark]
    public int SimpleThrowCatch()
    {
        try { throw new InvalidOperationException("x"); }
        catch (Exception e) { return e.Message.Length; }
    }

    [Benchmark]
    public int Rethrow()
    {
        try
        {
            try { throw new InvalidOperationException("x"); }
            catch { throw; }
        }
        catch (Exception e) { return e.Message.Length; }
    }

    [Benchmark]
    public int CatchAndThrowNew()
    {
        try
        {
            try { throw new InvalidOperationException("inner"); }
            catch { throw new ApplicationException("outer"); }
        }
        catch (Exception e) { return e.Message.Length; }
    }

    [Benchmark]
    public int NestedThrowCatch()
    {
        int r = 0;
        try { Outer(); }
        catch (Exception e) { r = e.Message.Length; }
        return r;
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    static void Outer()
    {
        try { Inner(); }
        catch (InvalidOperationException) { throw new ApplicationException("rewrapped"); }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    static void Inner() => throw new InvalidOperationException("x");

    [Benchmark]
    public int DeepThrowCatch()
    {
        try { Recurse(20); return 0; }
        catch (Exception e) { return e.Message.Length; }
    }

    [MethodImpl(MethodImplOptions.NoInlining)]
    static void Recurse(int n)
    {
        if (n == 0) throw new InvalidOperationException("deep");
        Recurse(n - 1);
    }
}

The interesting cases here:

Bench What it stresses
SimpleThrowCatch One ExInfo lifetime — was 1 eager LTO write + 1 reset; now 1 write at PopExInfos
Rethrow SafeUpdateLastThrownObject on rethrow — entirely removed
CatchAndThrowNew New throw inside a catch — an extra eager LTO sync per dispatch transition
NestedThrowCatch Multi-frame transition: previously two eager writes, now one lazy write
DeepThrowCatch Pure 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 thread src/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
Member Author

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.

Copilot AI review requested due to automatic review settings May 4, 2026 18:15
@max-charlamb max-charlamb force-pushed the dev/max-charlamb/lazy-lto-sync-v2 branch from a163d98 to 7ac958a Compare May 4, 2026 18:15
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

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

Comment thread src/coreclr/vm/exceptionhandling.cpp
Comment thread src/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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants