fix: Stop suppressing foreground service timing exceptions

Suppressing ForegroundServiceDidNotStartInTimeException and re-entering
Looper.loop() left zombie processes behind that kept collecting ANRs.
Always delegate to the previous handler instead.
This commit is contained in:
darken
2026-07-28 23:51:46 +02:00
committed by Matthias Urhahn
parent 8f71af8c5d
commit 28ad09e96d
2 changed files with 6 additions and 118 deletions
@@ -1,73 +1,19 @@
package eu.darken.capod
import android.os.Looper
import eu.darken.capod.common.debug.Bugs
import eu.darken.capod.common.debug.logging.Logging.Priority.ERROR
import eu.darken.capod.common.debug.logging.Logging.Priority.WARN
import eu.darken.capod.common.debug.logging.asLog
import eu.darken.capod.common.debug.logging.log
import java.util.concurrent.atomic.AtomicBoolean
import kotlin.system.exitProcess
internal class CapodUncaughtExceptionHandler(
private val previousHandler: Thread.UncaughtExceptionHandler?,
private val mainThreadProvider: () -> Thread = { Looper.getMainLooper().thread },
private val loopMainThread: () -> Unit = { Looper.loop() },
private val reportForegroundServiceTimingException: (Throwable) -> Unit = { throwable ->
Bugs.report(
tag = App.TAG,
message = "Foreground service timing exception suppressed",
exception = throwable,
)
},
private val cancelBeforeDelegate: (Throwable) -> Unit = {},
private val exit: (Int) -> Unit = { exitProcess(it) },
) : Thread.UncaughtExceptionHandler {
private val foregroundExceptionHandled = AtomicBoolean(false)
override fun uncaughtException(thread: Thread, throwable: Throwable) {
if (shouldSuppress(thread, throwable)) {
runCatching {
log(App.TAG, WARN) { "Suppressed foreground service timing exception: ${throwable.asLog()}" }
reportForegroundServiceTimingException(throwable)
}
val loopResult = runCatching { loopMainThread() }
if (loopResult.isSuccess) return
val loopFailure = loopResult.exceptionOrNull()!!
runCatching {
log(App.TAG, ERROR) {
"Main loop failed after foreground service timing exception suppression: ${loopFailure.asLog()}"
}
}
delegate(thread, loopFailure)
return
}
runCatching { log(App.TAG, ERROR) { "UNCAUGHT EXCEPTION: ${throwable.asLog()}" } }
delegate(thread, throwable)
}
private fun shouldSuppress(thread: Thread, throwable: Throwable): Boolean {
val isMainThread = runCatching { thread === mainThreadProvider() }.getOrDefault(false)
return throwable.isForegroundServiceTimingException() &&
isMainThread &&
foregroundExceptionHandled.compareAndSet(false, true)
}
private fun delegate(thread: Thread, throwable: Throwable) {
runCatching { cancelBeforeDelegate(throwable) }
previousHandler?.uncaughtException(thread, throwable) ?: exit(1)
}
}
internal fun Throwable.isForegroundServiceTimingException(): Boolean {
var current: Throwable? = this
while (current != null) {
if (current.javaClass.simpleName == "ForegroundServiceDidNotStartInTimeException") return true
current = current.cause
}
return false
}
@@ -7,67 +7,17 @@ import testhelpers.BaseTest
class CapodUncaughtExceptionHandlerTest : BaseTest() {
@Test
fun `suppresses first main thread foreground service timing exception`() {
fun `delegates 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)
}
@@ -77,9 +27,6 @@ class CapodUncaughtExceptionHandlerTest : BaseTest() {
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")
@@ -101,9 +48,6 @@ class CapodUncaughtExceptionHandlerTest : BaseTest() {
val throwable = IllegalStateException("boom")
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { throw AssertionError("loopMainThread should not run") },
reportForegroundServiceTimingException = { throw AssertionError("report should not run") },
cancelBeforeDelegate = { events += "cancel" },
exit = { throw AssertionError("exitProcess($it)") },
)
@@ -114,21 +58,19 @@ class CapodUncaughtExceptionHandlerTest : BaseTest() {
}
@Test
fun `delegates loop failure after suppression`() {
fun `delegates even when cancelBeforeDelegate throws`() {
val mainThread = Thread.currentThread()
val previousHandler = RecordingHandler()
val loopFailure = IllegalStateException("loop failed")
val throwable = IllegalStateException("boom")
val handler = CapodUncaughtExceptionHandler(
previousHandler = previousHandler,
mainThreadProvider = { mainThread },
loopMainThread = { throw loopFailure },
reportForegroundServiceTimingException = {},
cancelBeforeDelegate = { throw IllegalStateException("shutdown failed") },
exit = { throw AssertionError("exitProcess($it)") },
)
handler.uncaughtException(mainThread, ForegroundServiceDidNotStartInTimeException())
handler.uncaughtException(mainThread, throwable)
previousHandler.throwables shouldBe listOf(loopFailure)
previousHandler.throwables shouldBe listOf(throwable)
}
private class RecordingHandler : Thread.UncaughtExceptionHandler {