From 28ad09e96d8f4cde41e1ef20d8990854c08d751a Mon Sep 17 00:00:00 2001 From: darken Date: Tue, 28 Jul 2026 20:22:24 +0200 Subject: [PATCH] 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. --- .../capod/CapodUncaughtExceptionHandler.kt | 54 -------------- .../CapodUncaughtExceptionHandlerTest.kt | 70 ++----------------- 2 files changed, 6 insertions(+), 118 deletions(-) diff --git a/app/src/main/java/eu/darken/capod/CapodUncaughtExceptionHandler.kt b/app/src/main/java/eu/darken/capod/CapodUncaughtExceptionHandler.kt index f7c102d0..1d30e0da 100644 --- a/app/src/main/java/eu/darken/capod/CapodUncaughtExceptionHandler.kt +++ b/app/src/main/java/eu/darken/capod/CapodUncaughtExceptionHandler.kt @@ -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 -} diff --git a/app/src/test/java/eu/darken/capod/CapodUncaughtExceptionHandlerTest.kt b/app/src/test/java/eu/darken/capod/CapodUncaughtExceptionHandlerTest.kt index 23331fbb..4732e246 100644 --- a/app/src/test/java/eu/darken/capod/CapodUncaughtExceptionHandlerTest.kt +++ b/app/src/test/java/eu/darken/capod/CapodUncaughtExceptionHandlerTest.kt @@ -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() - 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() - 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 {