diff --git a/sentry/api/sentry.api b/sentry/api/sentry.api index 43de4164a4..43a6796092 100644 --- a/sentry/api/sentry.api +++ b/sentry/api/sentry.api @@ -7706,6 +7706,7 @@ public final class io/sentry/util/ExceptionUtils { public fun ()V public static fun findRootCause (Ljava/lang/Throwable;)Ljava/lang/Throwable; 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 9d6033a96c..329bc84ba8 100644 --- a/sentry/src/main/java/io/sentry/util/ExceptionUtils.java +++ b/sentry/src/main/java/io/sentry/util/ExceptionUtils.java @@ -29,4 +29,34 @@ public static boolean isIgnored( final @NotNull Throwable throwable) { return ignoredExceptionsForType.contains(throwable.getClass()); } + + /** + * Rethrows non-recoverable {@link Throwable}s that should never be swallowed: {@link + * 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 + || throwable instanceof LinkageError) { + 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 7517c24349..13f2cffc14 100644 --- a/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt +++ b/sentry/src/test/java/io/sentry/util/ExceptionUtilsTest.kt @@ -1,8 +1,13 @@ 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 class ExceptionUtilsTest { @Test @@ -18,4 +23,57 @@ class ExceptionUtilsTest { val ex = RuntimeException(cause) assertEquals(rootCause, ExceptionUtils.findRootCause(ex)) } + + @Test + fun `rethrowIfFatal rethrows OutOfMemoryError`() { + assertFails { ExceptionUtils.rethrowIfFatal(OutOfMemoryError()) } + } + + @Test + fun `rethrowIfFatal rethrows StackOverflowError`() { + assertFails { ExceptionUtils.rethrowIfFatal(StackOverflowError()) } + } + + @Test + fun `rethrowIfFatal rethrows ThreadDeath`() { + 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 { + ExceptionUtils.rethrowIfFatal(InterruptedException()) + assertTrue(Thread.currentThread().isInterrupted) + } finally { + // clear the interrupt flag so it doesn't leak into other tests + Thread.interrupted() + } + } + + @Test + fun `rethrowIfFatal does nothing for regular exceptions`() { + ExceptionUtils.rethrowIfFatal(RuntimeException()) + assertFalse(Thread.currentThread().isInterrupted) + } }