mirror of
https://github.com/d4rken-org/capod.git
synced 2026-09-15 10:46:12 -04:00
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:
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user