Implement ExceptionHandling.SetFatalErrorHandler#129543
Implement ExceptionHandling.SetFatalErrorHandler#129543AaronRobinsonMSFT wants to merge 31 commits into
ExceptionHandling.SetFatalErrorHandler#129543Conversation
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>
ExceptionHandling.SetFatalErrorHandler
There was a problem hiding this comment.
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 inSystem.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
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
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>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- 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>
- 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>
- 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; |
There was a problem hiding this comment.
These are large structures. The storage for them will be allocated eagerly for each thread. Can we do better than this?
There was a problem hiding this comment.
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
mallocsince 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.
There was a problem hiding this comment.
- We can't call
mallocsince 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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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,
CONTEXTandEXCEPTION_RECORDon 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.
There was a problem hiding this comment.
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.
- 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>
| 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; |
There was a problem hiding this comment.
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.
Implements the
ExceptionHandling.SetFatalErrorHandlerAPI (#101560) for both NativeAOT and CoreCLR. Mono throwsPlatformNotSupportedException.Changes
Managed API (
ExceptionHandling.cs)SetFatalErrorHandlerands_fatalErrorHandlerfor CoreCLR (Mono still throws PNSE)NativeAOT (
RuntimeExceptionHelpers.cs)FailFastafter crash info is written to stderrpfnGetFatalErrorLogimplemented via[UnmanagedCallersOnly]callbackSkipDefaultHandlerexits via_Exit/ExitProcesswithout crash dumpCoreCLR (
eepolicy.cpp)s_fatalErrorHandlerfrom managed static viaCoreLibBinderLogFatalErrorin bothHandleFatalErrorandHandleFatalStackOverflow. This ordering is required because on WindowsLogFatalErrorcan terminate the process viaWatsonLastChance/RaiseFailFastException, so the handler must run first to be guaranteed a chance to execute.RunDefaultHandler, the runtime proceeds withLogFatalError(crash-log output, Watson, crash dump).SkipDefaultHandler, the runtime skips the default crash output/dump and exits directly viaSafeExitProcess.PrintToStdErrAinto a per-thread bufferSkipDefaultHandlerbypasses the crash dumpPublic native header (
src/native/public/FatalErrorHandling.h)FatalErrorHandlerResultenum,FatalErrorInfostruct, callback typedefsTests (
src/tests/baseservices/exceptions/FatalErrorHandler/)Fixes #101560