fix: Harden uncaught exception safety net

Wrap logging, reporting, and Looper resume calls so the foreground service timing exception suppression cannot itself trigger another crash. Extract handler into a dedicated class with seams for unit tests.
This commit is contained in:
darken
2026-05-03 12:41:32 +02:00
committed by Matthias Urhahn
parent 06b41ec8eb
commit 82dc88faac
7 changed files with 356 additions and 46 deletions
@@ -0,0 +1,119 @@
package eu.darken.capod
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class CapodUncaughtExceptionHandlerTest : BaseTest() {
@Test
fun `suppresses first main thread foreground service timing exception`() {
val mainThread = Thread.currentThread()
val previousHandler = RecordingHandler()
val reports = mutableListOf<Throwable>()
var loopCalls = 0
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { loopCalls++ },
reportForegroundServiceTimingException = { reports += it },
exit = { throw AssertionError("exitProcess($it)") },
)
val throwable = ForegroundServiceDidNotStartInTimeException()
handler.uncaughtException(mainThread, throwable)
loopCalls shouldBe 1
reports shouldBe listOf(throwable)
previousHandler.throwables shouldBe emptyList()
}
@Test
fun `delegates repeated main thread foreground service timing exception`() {
val mainThread = Thread.currentThread()
val previousHandler = RecordingHandler()
val reports = mutableListOf<Throwable>()
var loopCalls = 0
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { loopCalls++ },
reportForegroundServiceTimingException = { reports += it },
exit = { throw AssertionError("exitProcess($it)") },
)
val first = ForegroundServiceDidNotStartInTimeException()
val second = ForegroundServiceDidNotStartInTimeException()
handler.uncaughtException(mainThread, first)
handler.uncaughtException(mainThread, second)
loopCalls shouldBe 1
reports shouldBe listOf(first)
previousHandler.throwables shouldBe listOf(second)
}
@Test
fun `delegates foreground service timing exception from non-main thread`() {
val mainThread = Thread.currentThread()
val workerThread = Thread()
val previousHandler = RecordingHandler()
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { throw AssertionError("loopMainThread should not run") },
reportForegroundServiceTimingException = { throw AssertionError("report should not run") },
exit = { throw AssertionError("exitProcess($it)") },
)
val throwable = ForegroundServiceDidNotStartInTimeException()
handler.uncaughtException(workerThread, throwable)
previousHandler.throwables shouldBe listOf(throwable)
}
@Test
fun `delegates unrelated main thread exception`() {
val mainThread = Thread.currentThread()
val previousHandler = RecordingHandler()
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { throw AssertionError("loopMainThread should not run") },
reportForegroundServiceTimingException = { throw AssertionError("report should not run") },
exit = { throw AssertionError("exitProcess($it)") },
)
val throwable = IllegalStateException("boom")
handler.uncaughtException(mainThread, throwable)
previousHandler.throwables shouldBe listOf(throwable)
}
@Test
fun `delegates loop failure after suppression`() {
val mainThread = Thread.currentThread()
val previousHandler = RecordingHandler()
val loopFailure = IllegalStateException("loop failed")
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { throw loopFailure },
reportForegroundServiceTimingException = {},
exit = { throw AssertionError("exitProcess($it)") },
)
handler.uncaughtException(mainThread, ForegroundServiceDidNotStartInTimeException())
previousHandler.throwables shouldBe listOf(loopFailure)
}
private class RecordingHandler : Thread.UncaughtExceptionHandler {
val throwables = mutableListOf<Throwable>()
override fun uncaughtException(thread: Thread, throwable: Throwable) {
throwables += throwable
}
}
private class ForegroundServiceDidNotStartInTimeException : RuntimeException("timed out")
}
@@ -0,0 +1,71 @@
package eu.darken.capod.common.debug
import android.app.Application
import eu.darken.capod.common.debug.autoreport.AutomaticBugReporter
import eu.darken.capod.common.debug.logging.Logging
import io.kotest.assertions.throwables.shouldNotThrowAny
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class BugsTest : BaseTest() {
@AfterEach
fun cleanup() {
Bugs.reporter = null
Logging.clearAll()
}
@Test
fun `report does not throw if logging fails`() {
Logging.clearAll()
Logging.install(ThrowingLogger())
shouldNotThrowAny {
Bugs.report(TAG, "Something failed", HostileThrowable())
}
}
@Test
fun `report does not throw if reporter fails`() {
var notified = false
Bugs.reporter = object : AutomaticBugReporter {
override fun setup(application: Application) = Unit
override fun notify(throwable: Throwable) {
notified = true
throw IllegalStateException("reporter failed")
}
}
shouldNotThrowAny {
Bugs.report(TAG, "Something failed", IllegalStateException("boom"))
}
notified shouldBe true
}
private class HostileThrowable : Throwable() {
override val message: String?
get() = throw IllegalStateException("message failed")
override fun toString(): String = throw IllegalStateException("toString failed")
}
private class ThrowingLogger : Logging.Logger {
override fun isLoggable(priority: Logging.Priority): Boolean = true
override fun log(
priority: Logging.Priority,
tag: String,
message: String,
metaData: Map<String, Any>?
) {
throw IllegalStateException("log failed")
}
}
companion object {
private const val TAG = "TEST"
}
}
@@ -0,0 +1,53 @@
package eu.darken.capod.common.debug.logging
import io.kotest.matchers.shouldBe
import org.junit.jupiter.api.Test
import testhelpers.BaseTest
class LoggingTest : BaseTest() {
@Test
fun `asLog renders normal throwable`() {
val log = IllegalStateException("boom").asLog()
log.contains("java.lang.IllegalStateException: boom") shouldBe true
log.contains("LoggingTest") shouldBe true
}
@Test
fun `asLog falls back when throwable rendering fails`() {
val log = HostileThrowable().asLog()
log.contains("HostileThrowable") shouldBe true
log.contains("stacktrace unavailable") shouldBe true
}
@Test
fun `logInternal ignores logger failures`() {
Logging.install(ThrowingLogger())
log("TEST") { "message" }
}
private class HostileThrowable : Throwable() {
override val message: String?
get() = throw IllegalStateException("message failed")
override fun toString(): String = throw IllegalStateException("toString failed")
}
private class ThrowingLogger : Logging.Logger {
override fun isLoggable(priority: Logging.Priority): Boolean {
throw IllegalStateException("isLoggable failed")
}
override fun log(
priority: Logging.Priority,
tag: String,
message: String,
metaData: Map<String, Any>?
) {
throw IllegalStateException("log failed")
}
}
}