Skip to content

fix: call the original terminate handler even after monitors are disabled - #8663

Open
devunt wants to merge 1 commit into
getsentry:mainfrom
penxle:fix/cpp-terminate-original-handler
Open

fix: call the original terminate handler even after monitors are disabled#8663
devunt wants to merge 1 commit into
getsentry:mainfrom
penxle:fix/cpp-terminate-original-handler

Conversation

@devunt

@devunt devunt commented Aug 4, 2026

Copy link
Copy Markdown

📜 Description

For fatal C++ exceptions, CPPExceptionTerminate() never invokes the terminate handler that was installed before the SDK, and the process instead aborts with libc++abi's "terminate_handler unexpectedly returned".

The chain:

  1. CPPExceptionTerminate() writes the crash report via sentrycrashcm_handleException().
  2. For fatal exceptions, sentrycrashcm_handleException() calls sentrycrashcm_setActiveMonitors(SentryCrashMonitorTypeNone) ("Exception is fatal. Restoring original handlers.").
  3. That runs this monitor's setEnabled(false), which restores std::set_terminate(g_originalTerminateHandler) and resets g_originalTerminateHandler to NULL.
  4. Control returns to CPPExceptionTerminate(), which then calls sentrycrashcm_cppexception_callOriginalTerminationHandler() — now a guaranteed no-op because the global is NULL.
  5. CPPExceptionTerminate() returns into libc++abi's std::__terminate, which aborts with "terminate_handler unexpectedly returned" (cxa_handlers.cpp).

The fix removes the g_originalTerminateHandler = NULL; reset in setEnabled(false), so sentrycrashcm_cppexception_callOriginalTerminationHandler() still invokes the pre-existing handler chain after the monitor teardown in step 3. This matches upstream KSCrash, which keeps its handlers and only gates its logic on the enabled flag.

Keeping the handler also covers the case an entry-time capture would miss: libc++abi stores the terminate handler per exception at throw time, so CPPExceptionTerminate can run for an exception thrown while the monitor was enabled even if the monitor was disabled before the exception terminated the process — at that point the global would already be NULL at function entry. Re-enabling overwrites the handler in setEnabled(true), and the existing NULL guard in callOriginalTerminationHandler() still covers the never-enabled case.

💡 Motivation and Context

Any runtime that installs its own std::terminate handler before SentrySDK.start silently loses it for fatal C++ exceptions. The concrete case where we found this: Kotlin Multiplatform apps on iOS (sentry-kotlin-multiplatform).

Kotlin/Native installs a terminate handler at runtime init that detects unhandled Kotlin exceptions (ExceptionObjHolder) and runs kotlin.native.setUnhandledExceptionHook — which is exactly where the sentry-kotlin-multiplatform SDK hooks in to capture the full Kotlin stack trace and tag the event so dropKotlinCrashEvent can dedup the opaque native report on next launch. Because of this bug, that handler never runs when a Kotlin exception reaches std::terminate through foreign (ObjC/C++) frames — e.g. any exception thrown inside Compose Multiplatform's render loop. The result in production: only an opaque C++ Exception: N12_GLOBAL__N_122ExceptionObjHolderImplE: (null) event, no Kotlin stack trace, no dedup (related context: getsentry/sentry-kotlin-multiplatform#476).

Apple crash report from production (sentry-cocoa 8.58.2, identical code path on main) showing step 5 — the terminate handler chain returned instead of the original handler aborting:

3   libc++abi.dylib  __abort_message + 132 (abort_message.cpp:66)
4   libc++abi.dylib  std::__terminate(void (*)()) + 28 (cxa_handlers.cpp:61)
5   libc++abi.dylib  __cxxabiv1::failed_throw(__cxxabiv1::__cxa_exception*) + 88 (cxa_exception.cpp:152)
6   libc++abi.dylib  __cxa_throw + 92 (cxa_exception.cpp:299)
7   Typie            __cxa_throw_decorator + 260
8   Typie            ExceptionObjHolder::Throw(ObjHeader*) + 52
9   Typie            ThrowException + 12
...

(cxa_handlers.cpp:61 is abort_message("terminate_handler unexpectedly returned"). Kotlin/Native's handler cannot be the returner: all of its paths end in abort()/_Exit().)

💚 How did you test it?

  • Verified the mechanism end-to-end from source (8.58.2 and main share the same logic) plus the production Apple crash report above.
  • Built a patched 8.58.2 XCFramework with an equivalent fix for the same root cause (an earlier variant that captured the handler at CPPExceptionTerminate entry) and pinned it in the affected app; a forced render-loop Kotlin exception on a device is our follow-up validation.
  • A unit test is impractical here: CPPExceptionTerminate is a static handler on a process-terminating path, and the existing tests only exercise the public sentrycrashcm_cppexception_callOriginalTerminationHandler() (whose behavior is unchanged).

📝 Checklist

You have to check all boxes before merging:

  • I added tests to verify the changes.
  • No new PII added or SDK only sends newly added PII if sendDefaultPII is enabled.
  • I updated the docs if needed.
  • I updated the wizard if needed.
  • Review from the native team if needed.
  • No breaking change or entry added to the changelog.
  • No breaking change for hybrid SDKs or communicated to hybrid SDKs.
  • Public API changes reviewed by another Mobile SDK team member or implemented according to the develop docs spec.
  • If I added a new public API, I also added it to the SentryObjC wrapper.

devunt added a commit to penxle/sentry-cocoa that referenced this pull request Aug 4, 2026

@NinjaLikesCheez NinjaLikesCheez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you for your contribution and the detailed debugging it took to find the source of the problem! Your analysis looks spot on, however I think the fix can be simpler - removing g_originalTerminateHandler = NULL; in setEnabled() would be a more correct fix since capturing the handler during an exception would miss cases where the monitor is enabled then disabled before an exception is handled.

Edit for maintainers: looking at upstream KSCrash - the monitor maintains it's handlers always and only runs it's logic if enabled so it doesn't suffer this issue.

…n monitor is disabled

For fatal C++ exceptions, CPPExceptionTerminate() calls
sentrycrashcm_handleException(), which disables all monitors and thereby
runs this monitor's setEnabled(false), resetting g_originalTerminateHandler
to NULL. The subsequent sentrycrashcm_cppexception_callOriginalTerminationHandler()
then becomes a no-op, so any terminate handler installed before the SDK
(e.g. Kotlin/Native's unhandled-exception reporter) never runs, and
libc++abi aborts with "terminate_handler unexpectedly returned".

The same applies when an exception is thrown while the monitor is enabled
but the monitor is disabled before the exception terminates the process:
libc++abi stores the terminate handler per exception at throw time, so
CPPExceptionTerminate can still run after setEnabled(false).

Keep g_originalTerminateHandler when disabling the monitor, matching
upstream KSCrash behavior. setEnabled(true) overwrites it anyway, and
callOriginalTerminationHandler() already guards against NULL for the
never-enabled case.
@devunt
devunt force-pushed the fix/cpp-terminate-original-handler branch from d2a1853 to ce7f233 Compare August 4, 2026 16:03
@devunt

devunt commented Aug 4, 2026

Copy link
Copy Markdown
Author

Thanks for the review, @NinjaLikesCheez — agreed, your version is more correct. The entry-time capture misses the case you describe: since libc++abi stores the terminate handler per exception at throw time (exception_header->terminateHandler in __cxa_throw, invoked by failed_throw), CPPExceptionTerminate can run for an exception thrown while the monitor was enabled even after the monitor has been disabled — and at that point the global is already NULL at function entry.

Rewrote the PR accordingly:

  • The fix is now just removing g_originalTerminateHandler = NULL; in setEnabled(false) (plus a comment explaining why the handler must be kept), reverting the previous entry-time capture. setEnabled(true) overwrites the handler on re-enable, and the existing NULL guard in sentrycrashcm_cppexception_callOriginalTerminationHandler() still covers the never-enabled case.
  • Rebased onto latest main and resolved the CHANGELOG conflict.

@NinjaLikesCheez

Copy link
Copy Markdown
Member

Thanks for the review, @NinjaLikesCheez — agreed, your version is more correct. The entry-time capture misses the case you describe: since libc++abi stores the terminate handler per exception at throw time (exception_header->terminateHandler in __cxa_throw, invoked by failed_throw), CPPExceptionTerminate can run for an exception thrown while the monitor was enabled even after the monitor has been disabled — and at that point the global is already NULL at function entry.

Rewrote the PR accordingly:

* The fix is now just removing `g_originalTerminateHandler = NULL;` in `setEnabled(false)` (plus a comment explaining why the handler must be kept), reverting the previous entry-time capture. `setEnabled(true)` overwrites the handler on re-enable, and the existing `NULL` guard in `sentrycrashcm_cppexception_callOriginalTerminationHandler()` still covers the never-enabled case.

* Rebased onto latest `main` and resolved the CHANGELOG conflict.

Thank you! Kicking off the CI now :)

@NinjaLikesCheez NinjaLikesCheez added run-full-ci Allows gated GitHub Action workflows to run for a labelled pull request and removed Waiting for: Community labels Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-full-ci Allows gated GitHub Action workflows to run for a labelled pull request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants