Skip to content

Implement ExceptionHandling.SetFatalErrorHandler#129543

Draft
AaronRobinsonMSFT wants to merge 31 commits into
dotnet:mainfrom
AaronRobinsonMSFT:set-fatal-error-handler
Draft

Implement ExceptionHandling.SetFatalErrorHandler#129543
AaronRobinsonMSFT wants to merge 31 commits into
dotnet:mainfrom
AaronRobinsonMSFT:set-fatal-error-handler

Conversation

@AaronRobinsonMSFT

@AaronRobinsonMSFT AaronRobinsonMSFT commented Jun 17, 2026

Copy link
Copy Markdown
Member

Implements the ExceptionHandling.SetFatalErrorHandler API (#101560) for both NativeAOT and CoreCLR. Mono throws PlatformNotSupportedException.

Changes

Managed API (ExceptionHandling.cs)

  • Enable SetFatalErrorHandler and s_fatalErrorHandler for CoreCLR (Mono still throws PNSE)

NativeAOT (RuntimeExceptionHelpers.cs)

  • Invoke handler from FailFast after crash info is written to stderr
  • Capture crash log alongside stderr writes in a pre-allocated 8KB buffer
  • pfnGetFatalErrorLog implemented via [UnmanagedCallersOnly] callback
  • SkipDefaultHandler exits via _Exit/ExitProcess without crash dump

CoreCLR (eepolicy.cpp)

  • Read s_fatalErrorHandler from managed static via CoreLibBinder
  • Invoke handler before LogFatalError in both HandleFatalError and HandleFatalStackOverflow. This ordering is required because on Windows LogFatalError can terminate the process via WatsonLastChance/RaiseFailFastException, so the handler must run first to be guaranteed a chance to execute.
    • If the handler returns RunDefaultHandler, the runtime proceeds with LogFatalError (crash-log output, Watson, crash dump).
    • If it returns SkipDefaultHandler, the runtime skips the default crash output/dump and exits directly via SafeExitProcess.
  • Crash log captured by tee-ing PrintToStdErrA into a per-thread buffer
  • SkipDefaultHandler bypasses the crash dump

Public native header (src/native/public/FatalErrorHandling.h)

  • Defines FatalErrorHandlerResult enum, FatalErrorInfo struct, callback typedefs

Tests (src/tests/baseservices/exceptions/FatalErrorHandler/)

  • Subprocess-based tests validating: SkipHandler, RunHandler, LogHandler, SetNull, SetTwice
  • Works on both CoreCLR and NativeAOT

Fixes #101560

AaronRobinsonMSFT and others added 2 commits June 17, 2026 15:05
Implement the ExceptionHandling.SetFatalErrorHandler API for NativeAOT.
The handler is invoked from RuntimeExceptionHelpers.FailFast before the
runtime performs its default crash handling (crash dump + abort).

- Add src/native/public/FatalErrorHandling.h defining the native
  FatalErrorInfo struct and FatalErrorHandlerResult enum
- Wire RegisterFatalErrorHandler as a no-op for NativeAOT (handler
  pointer stored in managed s_fatalErrorHandler field)
- Add crash log capture in FailFast alongside existing stderr output
- Implement pfnGetFatalErrorLog callback via UnmanagedCallersOnly
- SkipDefaultHandler exits via _Exit/ExitProcess instead of crash dump
- Consolidate ExceptionHandling partials: MONO||CORECLR throws PNSE
  inline, eliminating per-runtime partial files
- Add subprocess-based smoke tests validating handler invocation,
  SkipDefaultHandler/RunDefaultHandler, pfnGetFatalErrorLog callback,
  and API contract (null/double-set)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Wire up the user-registered fatal error handler in the CoreCLR runtime.
Read the managed ExceptionHandling.s_fatalErrorHandler static field via
CoreLibBinder and invoke the handler after LogFatalError completes in
both HandleFatalError and HandleFatalStackOverflow. If the handler
returns SkipDefaultHandler, exit without crash dump.

- Add ExceptionHandling class/field bindings to corelib.h
- Enable s_fatalErrorHandler field and SetFatalErrorHandler for CoreCLR
- Add crash log capture in PrintToStdErrA for pfnGetFatalErrorLog
- Include public/FatalErrorHandling.h for shared type definitions
- Fix test subprocess launch for CoreCLR (pass DLL path to corerun)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 17, 2026 22:24
@github-actions github-actions Bot added the area-ExceptionHandling-coreclr only use for closed issues label Jun 17, 2026
@AaronRobinsonMSFT AaronRobinsonMSFT changed the title Implement ExceptionHandling.SetFatalErrorHandler Implement ExceptionHandling.SetFatalErrorHandler Jun 17, 2026

Copilot AI 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 introduces the public System.Runtime.ExceptionServices.ExceptionHandling.SetFatalErrorHandler API and wires it up so CoreCLR and NativeAOT invoke a user-provided unmanaged callback during fatal-error paths, with a mechanism to retrieve the fatal-error log text.

Changes:

  • Adds ExceptionHandling.SetFatalErrorHandler(delegate* unmanaged<int, void*, int>) to the public surface and implements registration in System.Private.CoreLib.
  • Implements fatal-error handler invocation + crash-log capture in both CoreCLR (VM) and NativeAOT fail-fast paths.
  • Adds a new native public header (FatalErrorHandling.h) and a new subprocess-based test covering handler behaviors.
Show a summary per file
File Description
src/tests/baseservices/exceptions/FatalErrorHandler/FatalErrorHandlerTest.csproj Adds new standalone test project for fatal error handler scenarios.
src/tests/baseservices/exceptions/FatalErrorHandler/FatalErrorHandlerTest.cs Subprocess-based validation of handler invocation, skip/run default behavior, and log retrieval.
src/native/public/FatalErrorHandling.h Defines native ABI structs/enums/callback types for fatal error handling and log retrieval.
src/libraries/System.Runtime/ref/System.Runtime.cs Adds the new public ref-assembly API for SetFatalErrorHandler.
src/libraries/System.Private.CoreLib/src/System/Runtime/ExceptionServices/ExceptionHandling.cs Implements handler registration and stores function pointer for runtimes to read.
src/libraries/System.Private.CoreLib/src/Resources/Strings.resx Adds resource string for duplicate fatal handler registration.
src/coreclr/vm/util.hpp Declares crash-log capture helpers used by fatal-error handler plumbing.
src/coreclr/vm/util.cpp Implements stderr “tee” into a fixed crash-log buffer.
src/coreclr/vm/eepolicy.cpp Invokes the fatal handler after logging fatal errors / stack overflow and provides log callback.
src/coreclr/vm/corelib.h Adds CoreLibBinder field binding for ExceptionHandling.s_fatalErrorHandler.
src/coreclr/nativeaot/System.Private.CoreLib/src/System/RuntimeExceptionHelpers.cs Captures crash output into a buffer and invokes the fatal handler before default crash processing.

Copilot's findings

  • Files reviewed: 11/11 changed files
  • Comments generated: 5

Comment thread src/coreclr/vm/eepolicy.cpp Outdated
Comment thread src/tests/baseservices/exceptions/FatalErrorHandler/FatalErrorHandlerTest.cs Outdated
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 17, 2026 22:42

Copilot AI 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.

Copilot's findings

  • Files reviewed: 11/11 changed files
  • Comments generated: 6

Comment thread src/coreclr/vm/util.cpp Outdated
Comment thread src/coreclr/vm/eepolicy.cpp Outdated
Comment thread src/tests/baseservices/exceptions/FatalErrorHandler/FatalErrorHandlerTest.cs Outdated
Comment thread src/libraries/System.Runtime/ref/System.Runtime.cs
AaronRobinsonMSFT and others added 2 commits June 17, 2026 15:58
The SkipDefaultHandler path should terminate immediately without running
atexit handlers, which can deadlock in a corrupted process. Replace the
call to exit() (via Interop.Sys.Exit) with _exit() (via a new
Interop.Sys._Exit P/Invoke) in the NativeAOT FailFast path, matching
CoreCLR's native _exit() semantics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Comment thread src/tests/baseservices/exceptions/FatalErrorHandler/FatalErrorHandlerTest.cs Outdated
Comment thread src/tests/baseservices/exceptions/FatalErrorHandler/FatalErrorHandlerTest.cs Outdated
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 17, 2026 23:16

Copilot AI 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.

Copilot's findings

  • Files reviewed: 17/17 changed files
  • Comments generated: 4

Comment thread src/tests/baseservices/exceptions/FatalErrorHandler/FatalErrorHandlerTest.cs Outdated
Comment thread src/coreclr/vm/eepolicy.cpp Outdated
Comment thread src/native/libs/System.Native/pal_threading.c Outdated
Comment thread src/native/libs/System.Native/pal_threading_wasi.c Outdated
- Use C99 _Exit() instead of _exit() to avoid unistd.h dependency
- Add COR_E_FAILFAST to IsCrashExitCode for Windows CoreCLR
- Suppress unused parameter warning in GetFatalErrorLogCallback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
On Windows, WatsonLastChance calls RaiseFailFastException which
terminates the process before InvokeFatalErrorHandler is reached.
Move the handler invocation before the Watson/debugger code path
in both HandleFatalError and HandleFatalStackOverflow.

In HandleFatalError, call LogInfoForFatalError directly first to
populate the crash log buffer for the handler, then invoke the
handler, then proceed with LogFatalError for ETW and Watson.

Exclude FatalErrorHandlerTest from Mono runs since
SetFatalErrorHandler throws PlatformNotSupportedException on Mono.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings June 18, 2026 19:12
Copilot AI review requested due to automatic review settings July 6, 2026 20:59

Copilot AI 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.

Copilot's findings

  • Files reviewed: 30/30 changed files
  • Comments generated: 3

Comment thread src/coreclr/vm/eepolicy.cpp
Comment thread src/coreclr/vm/eepolicy.cpp Outdated
Comment thread src/coreclr/nativeaot/Runtime/EHHelpers.cpp Outdated
AaronRobinsonMSFT and others added 4 commits July 7, 2026 10:37
- HandleFatalError's RunDefault path called LogInfoForFatalError directly and
  then LogFatalError (which calls it again), tripping the same-thread re-entrancy
  guard and printing a second, spurious crash log. Emit the crash log once.
- EmitCrashInfo wrote the "Fatal error." header for every non-FailFast exit code,
  including stack overflow, whose default output is "Stack overflow.". Skip the
  header for COR_E_STACKOVERFLOW so the two paths agree.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Previously the native signal/exception records captured for an unhandled
hardware fault were tracked in a single per-thread slot. When a nested
hardware fault was handled while an outer fault was still in flight (e.g.
an exception filter that triggers and swallows its own fault during the
outer fault's first pass), swallowing the inner fault cleared the shared
slot and lost the outer fault's records. The outer fault then reached the
fatal error handler with no platform records to surface.

Store the captured records on each fault's ExInfo instead. RhThrowHwEx
stackallocs a per-fault buffer (a two-pointer header followed by the raw
siginfo_t/ucontext_t on Unix, or EXCEPTION_RECORD/CONTEXT on Windows),
copies the transient signal-handler records into it, and publishes it as
the pending unhandled hand-off. Because the buffer lives on the throwing
frame (which is never unwound before the unhandled FailFast) and is keyed
to the fault's ExInfo, a nested fault handled in between cannot clobber it.

The capture+publish happens before GetClasslibException so that uncatchable
faults (access violation, illegal/privileged instruction, in-page error),
which FailFast directly out of the classlib GetRuntimeException and never
return, still surface their records. Catchable faults that dispatch and are
caught clear the hand-off in the second pass (gated on HardwareFault so a
nested software throw does not clear an in-flight hardware fault's records);
faults that go unhandled re-publish from their ExInfo in
UnhandledExceptionFailFastViaClasslib, which is correct even when nested
faults ran in between.

RhpCaptureHardwareExceptionRecordsToBuffer takes the buffer length and
asserts it is at least RhpGetHardwareExceptionRecordsBufferSize.

Adds a NativeAOT nested-hardware-fault regression test.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The per-fault buffer laid out siginfo_t/ucontext_t (Unix) and
EXCEPTION_RECORD/CONTEXT (Windows) at running byte offsets. Because
sizeof(siginfo_t) and sizeof(EXCEPTION_RECORD) are not multiples of the
alignment required by the following record, the ucontext/CONTEXT copy
landed at a misaligned address. ucontext (Apple mcontext NEON state) and
CONTEXT (x64/arm64) require 16-byte alignment, so the struct copy and any
later typed access through the header pointers were misaligned (UB, and a
compiler may emit aligned SIMD stores).

Place each record at an ALIGN_UP-ed address for its own alignment, grow
RhpGetHardwareExceptionRecordsBufferSize by the worst-case padding, and
assert the incoming buffer is at least pointer-aligned for the header.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the heap-allocated string?[] backing s_crashLogFragments with an
inline InlineArray16<string?>, moving the 16 slots into the already-rooted
static storage and removing a persistent heap object and an indirection.
Delete the now-unused bespoke InlineArray16Strings struct in favor of the
shared generic.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 7, 2026 22:41

Copilot AI 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.

Copilot's findings

  • Files reviewed: 38/38 changed files
  • Comments generated: 6

Comment thread src/native/public/FatalErrorHandling.h
Comment thread src/coreclr/pal/src/include/pal/seh.hpp
Comment thread src/coreclr/vm/eepolicy.cpp
Comment thread src/coreclr/vm/qcallentrypoints.cpp Outdated
Comment thread src/tests/baseservices/exceptions/FatalErrorHandler/FatalErrorHandlerTest.cs Outdated
Comment thread src/coreclr/vm/eepolicy.cpp
@steveisok steveisok requested a review from lateralusX July 7, 2026 23:08
- Preserve outer fault's native exception pointers across nested faults
- Make crash-context storage thread-local in eepolicy
- Assign explicit values to FatalErrorProperty enumerators
- Reorder qcall entry and fix a stale test comment

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
// context is redirected to RhpThrowHwEx and resumes on the original stack), so they
// are copied here before the handler returns.
static thread_local siginfo_t t_hardwareExceptionSiginfo;
static thread_local ucontext_t t_hardwareExceptionUContext;

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.

These are large structures. The storage for them will be allocated eagerly for each thread. Can we do better than this?

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.

Can we do better than this?

I'll admit ignorance in native AOT early to avoid making a fool of myself, but I have a few thoughts.

  • We can't call malloc since we're in a signal handler context and that is generally not advisable.
  • Ask the GC to set aside some thread static pinned or local memory for exception related conditions. This would be on one of its heaps so would be allocated and accounted for in the GC budget.
  • Overallocate a thread-based data structure in nativeAOT and have it "ready" whenever this occurs. Unclear what data structure would be available and thread specific. In CoreCLR I'd reach for old faithful, class Thread.
  • Get inspired by the Apollo launch computer tricks and realize that once we're this situation we could overwrite/reuse some existing, but unnecessary, allocated memory. I really dislike this unless there is something specifically identified as "volatile" memory and not used for diagnostics.

Open to other suggestions.

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.

@janvorli in case he has thoughts.

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 can't call malloc since we're in a signal handler context and that is generally not advisable.

Well, you are generally not supposed to even access thread local variables from signal handler as it can allocate (and with recent glibc, even if the thread local was accessed before, it can still trigger allocation).

However, it is a problem only for signal handlers that can be invoked from a place that's not reentrant, like malloc etc. It should not be a problem for hardware exceptions from managed code, so using malloc should be fine in this case.

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.

And actually, right after this capturing we are going to execute the exception handling code, which is managed code. It could have called a lot of stuff including allocations, thread local access etc. Both from the EH code itself and finallys called on the way. So trying to avoid malloc here seems pointless.

@lateralusX lateralusX Jul 9, 2026

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.

For the in-proc crash reporter callback we only need the siginfo_t and ucontext_t from the crashing thread that ends up triggering the crash report. In this case it will be the thread calling the fatal error handler that will potentially call the in-proc crash reporter through provided callback. The fatal error handler should only be called from one thread ending up with an unhandled exception, if there were more threads with unhandled exceptions in flight, they should wait.

@jkotas jkotas Jul 9, 2026

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 we need siginfo_t and ucontext_t for unhandled managed exception (that includes unhandled managed exceptions that got created by transforming a signal into a managed exception).

siginfo_t and ucontext_t makes sense for fatal crashes in unmanaged code like GC that are not transformed into a managed exception and that we know are fatal right away. We should not need to save siginfo_t and ucontext_t anywhere in these cases.

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 have sufficient information to diagnose exceptions about the context in regular crash dumps without saving the original signal/exception context explicitly like this. I do not think this callback needs to provide more than what's in regular crash dumps.

I think the callback should be only providing information that can be extracted at the point of the crash dump. We may need to change what's available for managed exceptions. For example, I do not think the original signal details are interesting for a segfault that we have decided to translate to a managed NullReferenceException.

+1 on this. Especially on coreclr, I am not sure if there is an extra value in providing the original ucontext / siginfo when we have them translated into CONTEXT and EXCEPTION_RECORD and available in the fatal error handler.

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 am not sure if there is an extra value in providing the original ucontext / siginfo when we have them translated into CONTEXT and EXCEPTION_RECORD and available in the fatal error handler.

@janvorli So I'm clear on this. Are you saying for unmanaged we would always provide data in the CONTEXT and EXCEPTION_RECORD formats? If so, I don't want to pass transformed data. It means that all consumers will need some sort of PAL for this.

This conversation has helped me quite a bit, thanks. Here is what I'm hearing and how I think we should proceed.

  • Managed fatal exception - provide the minimal data, which should be sufficient with the IP. No need for any platform specific exception details (that is, CONTEXT and EXCEPTION_RECORD on Windows or signal data structures on non-Windows).
  • Unamanged fatal exception - provide the platform specific data structures in that case, but we don't need to handle the duplicate exception case and we can just forward the existing data structures. We would pass in the Windows, signal for linux, and mach data for macOS. They just don't need to be stored.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Are you saying for unmanaged we would always provide data in the CONTEXT and EXCEPTION_RECORD formats? If so, I don't want to pass transformed data. It means that all consumers will need some sort of PAL for this.

Yeah, we would need to provide header with CONTEXT / EXCEPTION_RECORD definitions on Unix. I guess you are right that it would be cumbersome.

Comment thread src/native/public/FatalErrorHandling.h
Copilot AI review requested due to automatic review settings July 8, 2026 20:17

Copilot AI 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.

Copilot's findings

  • Files reviewed: 38/38 changed files
  • Comments generated: 3

Comment thread src/coreclr/vm/eepolicy.cpp Outdated
Comment thread src/tests/baseservices/exceptions/FatalErrorHandler/FatalErrorHandlerNative.cpp Outdated
- Read the fatal error handler pointer with a volatile load on both runtimes
- Mark unused parameters in the native test callbacks

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 8, 2026 20:46

Copilot AI 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.

Copilot's findings

  • Files reviewed: 38/38 changed files
  • Comments generated: 4

Comment thread src/native/public/FatalErrorHandling.h
Comment thread src/native/public/FatalErrorHandling.h
Copilot AI review requested due to automatic review settings July 9, 2026 02:58

Copilot AI 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.

Copilot's findings

  • Files reviewed: 38/38 changed files
  • Comments generated: 2

Comment thread src/coreclr/vm/eepolicy.cpp
Comment on lines +734 to +737
using FatalErrorHandlerFunc = int (DOTNET_CALLCONV *)(int hresult, FatalErrorPropertyGetter getProperty);

void* s_fatalErrorHandler = NULL;

// error handler when a hardware fault goes unhandled. The OS-provided records are
// only valid for the duration of the vectored handler, so they are copied here
// before the faulting context is redirected to RhpThrowHwEx.
static thread_local EXCEPTION_RECORD t_hardwareExceptionRecord;

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 guess the same argument could be for the thread locals here as on the unix path above. On Windows the EXCEPTION_RECORD is 152 bytes on 64-bit platforms, so could probably be tls allocated. CONTEXT is a little smaller than ucontext_t, but still 1232 bytes. On Windows it should be safe to call VirtualAlloc from exception handler, but if it's handling due to an OOM situation, then it the call can fail, so we will need a fallback strategy.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[API Proposal]: Overriding the default behavior in case of unhandled exceptions and fatal errors.

6 participants