From 6f3de1f87c8aac33f9ea1d3a77e3a6f1eacdbf57 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Fri, 7 Aug 2026 09:14:48 +0200 Subject: [PATCH 1/3] 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) + } } From de61c035dd257caa71d39d153912c05e752deac0 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 11 Aug 2026 10:23:46 +0200 Subject: [PATCH 2/3] refactor(core): Rename ExceptionUtils.handleFatal to rethrowIfFatal Co-Authored-By: Claude Opus 5 (1M context) --- sentry/api/sentry.api | 2 +- .../java/io/sentry/util/ExceptionUtils.java | 12 +++++------ .../java/io/sentry/util/ExceptionUtilsTest.kt | 20 +++++++++---------- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index a7f8e22d14b..43a67960926 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7705,8 +7705,8 @@ 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 + public static fun rethrowIfFatal (Ljava/lang/Throwable;)V } public final class io/sentry/util/FileUtils { diff --git a/sentry/src/main/java/io/sentry/util/ExceptionUtils.java b/sentry/src/main/java/io/sentry/util/ExceptionUtils.java index 1e3236f5dbc..c9c1bd37fc3 100644 --- a/sentry/src/main/java/io/sentry/util/ExceptionUtils.java +++ b/sentry/src/main/java/io/sentry/util/ExceptionUtils.java @@ -31,15 +31,15 @@ public static boolean isIgnored( } /** - * 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. + * Rethrows non-recoverable {@link Throwable}s that should never be swallowed: {@link + * VirtualMachineError} (e.g. OutOfMemoryError/StackOverflowError) and {@link ThreadDeath} are + * rethrown as-is. For {@link InterruptedException}, the thread's interrupted status is restored + * 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) { + public static void rethrowIfFatal(final @NotNull Throwable throwable) { // VirtualMachineError covers OutOfMemoryError, StackOverflowError, InternalError, and // UnknownError if (throwable instanceof VirtualMachineError || throwable instanceof ThreadDeath) { diff --git a/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt b/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt index 972585db995..2ed5d1dfd2e 100644 --- a/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt @@ -23,24 +23,24 @@ class ExceptionUtilsTest { } @Test - fun `handleFatal rethrows OutOfMemoryError`() { - assertFails { ExceptionUtils.handleFatal(OutOfMemoryError()) } + fun `rethrowIfFatal rethrows OutOfMemoryError`() { + assertFails { ExceptionUtils.rethrowIfFatal(OutOfMemoryError()) } } @Test - fun `handleFatal rethrows StackOverflowError`() { - assertFails { ExceptionUtils.handleFatal(StackOverflowError()) } + fun `rethrowIfFatal rethrows StackOverflowError`() { + assertFails { ExceptionUtils.rethrowIfFatal(StackOverflowError()) } } @Test - fun `handleFatal rethrows ThreadDeath`() { - assertFails { ExceptionUtils.handleFatal(ThreadDeath()) } + fun `rethrowIfFatal rethrows ThreadDeath`() { + assertFails { ExceptionUtils.rethrowIfFatal(ThreadDeath()) } } @Test - fun `handleFatal restores interrupt flag for InterruptedException without rethrowing`() { + fun `rethrowIfFatal restores interrupt flag for InterruptedException without rethrowing`() { try { - ExceptionUtils.handleFatal(InterruptedException()) + ExceptionUtils.rethrowIfFatal(InterruptedException()) assertTrue(Thread.currentThread().isInterrupted) } finally { // clear the interrupt flag so it doesn't leak into other tests @@ -49,8 +49,8 @@ class ExceptionUtilsTest { } @Test - fun `handleFatal does nothing for regular exceptions`() { - ExceptionUtils.handleFatal(RuntimeException()) + fun `rethrowIfFatal does nothing for regular exceptions`() { + ExceptionUtils.rethrowIfFatal(RuntimeException()) assertFalse(Thread.currentThread().isInterrupted) } } From 5cf3e0bff4f9d25d4a09f0b52e970632e67345c0 Mon Sep 17 00:00:00 2001 From: Markus Hintersteiner Date: Tue, 11 Aug 2026 10:42:53 +0200 Subject: [PATCH 3/3] feat(core): Rethrow LinkageError from ExceptionUtils.rethrowIfFatal --- .../java/io/sentry/util/ExceptionUtils.java | 20 ++++++++++++---- .../java/io/sentry/util/ExceptionUtilsTest.kt | 23 +++++++++++++++++++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/sentry/src/main/java/io/sentry/util/ExceptionUtils.java b/sentry/src/main/java/io/sentry/util/ExceptionUtils.java index c9c1bd37fc3..329bc84ba81 100644 --- a/sentry/src/main/java/io/sentry/util/ExceptionUtils.java +++ b/sentry/src/main/java/io/sentry/util/ExceptionUtils.java @@ -32,17 +32,27 @@ public static boolean isIgnored( /** * Rethrows non-recoverable {@link Throwable}s that should never be swallowed: {@link - * VirtualMachineError} (e.g. OutOfMemoryError/StackOverflowError) and {@link ThreadDeath} are - * rethrown as-is. For {@link InterruptedException}, the thread's interrupted status is restored - * instead of rethrowing, since it is a checked exception. All other throwables are left untouched - * for the caller to handle/log/ignore as before. + * VirtualMachineError} (e.g. OutOfMemoryError/StackOverflowError), {@link ThreadDeath} and {@link + * LinkageError} are rethrown as-is. For {@link InterruptedException}, the thread's interrupted + * status is restored instead of rethrowing, since it is a checked exception. All other throwables + * are left untouched for the caller to handle/log/ignore as before. + * + *

Note on {@link LinkageError}: its subclasses (e.g. {@link NoClassDefFoundError}, {@link + * NoSuchMethodError}, {@link AbstractMethodError}, {@link UnsatisfiedLinkError}) are also the + * expected runtime signal for a missing or version-mismatched optional dependency, which is a + * normal condition for integrations built against {@code compileOnly} dependencies. Call sites + * that probe for such a dependency must catch the relevant {@link LinkageError} subclass locally + * before delegating to this method — see {@code SentrySQLiteDriver.hasConnectionPool} + * and {@link LoadClass} for examples. * * @param throwable - the throwable to check */ public static void rethrowIfFatal(final @NotNull Throwable throwable) { // VirtualMachineError covers OutOfMemoryError, StackOverflowError, InternalError, and // UnknownError - if (throwable instanceof VirtualMachineError || throwable instanceof ThreadDeath) { + if (throwable instanceof VirtualMachineError + || throwable instanceof ThreadDeath + || throwable instanceof LinkageError) { throw (Error) throwable; } if (throwable instanceof InterruptedException) { diff --git a/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt b/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt index 2ed5d1dfd2e..13f2cffc142 100644 --- a/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt @@ -1,9 +1,11 @@ package io.sentry.util +import com.google.common.truth.Truth.assertThat import java.lang.RuntimeException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFails +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -37,6 +39,27 @@ class ExceptionUtilsTest { assertFails { ExceptionUtils.rethrowIfFatal(ThreadDeath()) } } + @Test + fun `rethrowIfFatal rethrows NoClassDefFoundError as-is`() { + val error = NoClassDefFoundError() + val thrown = assertFailsWith { ExceptionUtils.rethrowIfFatal(error) } + assertThat(thrown).isSameInstanceAs(error) + } + + @Test + fun `rethrowIfFatal rethrows NoSuchMethodError as-is`() { + val error = NoSuchMethodError() + val thrown = assertFailsWith { ExceptionUtils.rethrowIfFatal(error) } + assertThat(thrown).isSameInstanceAs(error) + } + + @Test + fun `rethrowIfFatal rethrows UnsatisfiedLinkError as-is`() { + val error = UnsatisfiedLinkError() + val thrown = assertFailsWith { ExceptionUtils.rethrowIfFatal(error) } + assertThat(thrown).isSameInstanceAs(error) + } + @Test fun `rethrowIfFatal restores interrupt flag for InterruptedException without rethrowing`() { try {