From 6f3de1f87c8aac33f9ea1d3a77e3a6f1eacdbf57 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 7 Aug 2026 09:14:48 +0200 Subject: [PATCH] feat(core): Add ExceptionUtils.handleFatal to rethrow non-recoverable throwables --- sentry/api/sentry.api | 1 + .../java/io/sentry/util/ExceptionUtils.java | 20 +++++++++++ .../java/io/sentry/util/ExceptionUtilsTest.kt | 35 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 43de4164a4f..a7f8e22d14b 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7705,6 +7705,7 @@ public final class io/sentry/util/EventSizeLimitingUtils { public final class io/sentry/util/ExceptionUtils { public fun ()V public static fun findRootCause (Ljava/lang/Throwable;)Ljava/lang/Throwable; + public static fun handleFatal (Ljava/lang/Throwable;)V public static fun isIgnored (Ljava/util/Set;Ljava/lang/Throwable;)Z } diff --git a/sentry/src/main/java/io/sentry/util/ExceptionUtils.java b/sentry/src/main/java/io/sentry/util/ExceptionUtils.java index 9d6033a96c3..1e3236f5dbc 100644 --- a/sentry/src/main/java/io/sentry/util/ExceptionUtils.java +++ b/sentry/src/main/java/io/sentry/util/ExceptionUtils.java @@ -29,4 +29,24 @@ public static boolean isIgnored( final @NotNull Throwable throwable) { return ignoredExceptionsForType.contains(throwable.getClass()); } + + /** + * Handles non-recoverable {@link Throwable}s that should never be swallowed. Rethrows {@link + * VirtualMachineError} (e.g. OutOfMemoryError/StackOverflowError) and {@link ThreadDeath} as-is. + * For {@link InterruptedException}, restores the thread's interrupted status instead of + * rethrowing, since it is a checked exception. All other throwables are left untouched for the + * caller to handle/log/ignore as before. + * + * @param throwable - the throwable to check + */ + public static void handleFatal(final @NotNull Throwable throwable) { + // VirtualMachineError covers OutOfMemoryError, StackOverflowError, InternalError, and + // UnknownError + if (throwable instanceof VirtualMachineError || throwable instanceof ThreadDeath) { + throw (Error) throwable; + } + if (throwable instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + } } diff --git a/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt b/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt index 7517c243497..972585db995 100644 --- a/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt @@ -3,6 +3,9 @@ package io.sentry.util import java.lang.RuntimeException import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertFalse +import kotlin.test.assertTrue class ExceptionUtilsTest { @Test @@ -18,4 +21,36 @@ class ExceptionUtilsTest { val ex = RuntimeException(cause) assertEquals(rootCause, ExceptionUtils.findRootCause(ex)) } + + @Test + fun `handleFatal rethrows OutOfMemoryError`() { + assertFails { ExceptionUtils.handleFatal(OutOfMemoryError()) } + } + + @Test + fun `handleFatal rethrows StackOverflowError`() { + assertFails { ExceptionUtils.handleFatal(StackOverflowError()) } + } + + @Test + fun `handleFatal rethrows ThreadDeath`() { + assertFails { ExceptionUtils.handleFatal(ThreadDeath()) } + } + + @Test + fun `handleFatal restores interrupt flag for InterruptedException without rethrowing`() { + try { + ExceptionUtils.handleFatal(InterruptedException()) + assertTrue(Thread.currentThread().isInterrupted) + } finally { + // clear the interrupt flag so it doesn't leak into other tests + Thread.interrupted() + } + } + + @Test + fun `handleFatal does nothing for regular exceptions`() { + ExceptionUtils.handleFatal(RuntimeException()) + assertFalse(Thread.currentThread().isInterrupted) + } }