From b8a5bcf9f40ec0069b01718a745d7dbd76768654 Mon Sep 17 00:00:00 2001 From: Nitheesh D R Date: Tue, 19 May 2026 18:33:25 +0530 Subject: [PATCH 1/2] fix: guard a11y NPE via uncaught-exception handler path on macOS --- .../zed/rainxch/githubstore/A11yCrashGuard.kt | 80 +++++++++++++------ 1 file changed, 57 insertions(+), 23 deletions(-) diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/A11yCrashGuard.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/A11yCrashGuard.kt index 42b56ca90..180481dd0 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/A11yCrashGuard.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/A11yCrashGuard.kt @@ -14,24 +14,72 @@ import java.util.concurrent.atomic.AtomicBoolean * androidx.compose.ui.platform.a11y.SemanticsOwnerAccessibility.accessibleParentOf * -> sun.lwawt.macosx.CAccessible$AXChangeNotifier.propertyChange * - * The uncaught exception poisons the AWT EventDispatchThread and the app - * appears to freeze/crash on click. Installing a filtering [EventQueue] - * swallows only that specific NPE so the EDT keeps draining events. - * Trade-off: macOS VoiceOver may miss updates on those removed nodes. - * Remove once the upstream fix lands (track against Compose MP 1.11+). + * The NPE surfaces via two propagation paths and both are guarded here: * - * See [GitHub-Store#330](https://github.com/OpenHub-Store/GitHub-Store/issues/330). + * 1. **EDT path** — NPE escapes `DispatchedTask.run` and propagates through + * the AWT event queue dispatch chain. [FilteringEventQueue] swallows it so + * the EDT keeps draining events. + * + * 2. **Coroutine-failure path** — `BaseContinuationImpl.resumeWith` catches the + * NPE and routes the coroutine failure through `handleCoroutineException` to + * the default uncaught-exception handler, bypassing [FilteringEventQueue]. + * The handler wrapper installed in [install] intercepts this path before it + * reaches [CrashReporter], preventing a spurious crash dump. + * + * Trade-off: macOS VoiceOver may miss updates on those removed nodes for the + * remainder of the session. Remove once the upstream fix lands (track against + * Compose MP 1.11+). + * + * See [GitHub-Store#330](https://github.com/OpenHub-Store/GitHub-Store/issues/330) + * and [GitHub-Store#640](https://github.com/OpenHub-Store/GitHub-Store/issues/640). */ object A11yCrashGuard { + private val warned = AtomicBoolean(false) + fun install() { val osName = System.getProperty("os.name")?.lowercase().orEmpty() if (!osName.contains("mac")) return + + // Path 1: NPE propagates out of the coroutine dispatcher and through the + // AWT EventQueue dispatch chain. Toolkit.getDefaultToolkit().systemEventQueue.push(FilteringEventQueue()) + + // Path 2: NPE is intercepted by BaseContinuationImpl.resumeWith and + // forwarded to the default uncaught-exception handler via coroutine + // failure handling. Wrap the handler that CrashReporter already installed + // so all non-a11y exceptions still reach it. + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + if (isComposeA11yNpe(throwable)) { + if (warned.compareAndSet(false, true)) { + System.err.println( + "[A11yCrashGuard] Suppressed Compose a11y NPE via uncaught-exception path " + + "(known issue, see GitHub-Store#330 / #640). Further occurrences silenced.", + ) + } + return@setDefaultUncaughtExceptionHandler + } + previous?.uncaughtException(thread, throwable) + } } - private class FilteringEventQueue : EventQueue() { - private val warned = AtomicBoolean(false) + private fun isComposeA11yNpe(throwable: Throwable): Boolean { + if (throwable !is NullPointerException) return false + var current: Throwable? = throwable + while (current != null) { + if (current.stackTrace.any { frame -> + frame.className.startsWith("androidx.compose.ui.platform.a11y") || + frame.className.startsWith("sun.lwawt.macosx.CAccessible") + } + ) { + return true + } + current = current.cause + } + return false + } + private class FilteringEventQueue : EventQueue() { override fun dispatchEvent(event: AWTEvent) { try { super.dispatchEvent(event) @@ -40,7 +88,7 @@ object A11yCrashGuard { if (warned.compareAndSet(false, true)) { System.err.println( "[A11yCrashGuard] Suppressed Compose a11y NPE on macOS " + - "(known issue, see GitHub-Store#330). Further occurrences silenced.", + "(known issue, see GitHub-Store#330 / #640). Further occurrences silenced.", ) } return @@ -48,19 +96,5 @@ object A11yCrashGuard { throw npe } } - - private fun isComposeA11yNpe(throwable: Throwable): Boolean { - var current: Throwable? = throwable - while (current != null) { - if (current.stackTrace.any { frame -> - frame.className.startsWith("androidx.compose.ui.platform.a11y") - } - ) { - return true - } - current = current.cause - } - return false - } } } From f937a70f57fa07f8ee921692bebcd64858b3571b Mon Sep 17 00:00:00 2001 From: Nitheesh D R Date: Tue, 19 May 2026 21:20:06 +0530 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20address=20Greptile=20review=20?= =?UTF-8?q?=E2=80=94=20split=20warned=20flags,=20narrow=20fingerprint,=20g?= =?UTF-8?q?uard=20null=20handler?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../zed/rainxch/githubstore/A11yCrashGuard.kt | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/A11yCrashGuard.kt b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/A11yCrashGuard.kt index 180481dd0..bb51f2d73 100644 --- a/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/A11yCrashGuard.kt +++ b/composeApp/src/jvmMain/kotlin/zed/rainxch/githubstore/A11yCrashGuard.kt @@ -34,8 +34,12 @@ import java.util.concurrent.atomic.AtomicBoolean * and [GitHub-Store#640](https://github.com/OpenHub-Store/GitHub-Store/issues/640). */ object A11yCrashGuard { - private val warned = AtomicBoolean(false) + // Separate flags per path so each path logs its first suppression independently. + private val warnedEdt = AtomicBoolean(false) + private val warnedUncaught = AtomicBoolean(false) + // Must be called after CrashReporter.install() so the uncaught-exception handler + // chain is: A11yCrashGuard (filter) -> CrashReporter (log + dump) -> JVM default. fun install() { val osName = System.getProperty("os.name")?.lowercase().orEmpty() if (!osName.contains("mac")) return @@ -51,7 +55,7 @@ object A11yCrashGuard { val previous = Thread.getDefaultUncaughtExceptionHandler() Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> if (isComposeA11yNpe(throwable)) { - if (warned.compareAndSet(false, true)) { + if (warnedUncaught.compareAndSet(false, true)) { System.err.println( "[A11yCrashGuard] Suppressed Compose a11y NPE via uncaught-exception path " + "(known issue, see GitHub-Store#330 / #640). Further occurrences silenced.", @@ -59,7 +63,10 @@ object A11yCrashGuard { } return@setDefaultUncaughtExceptionHandler } + // Forward to CrashReporter (or JVM default if previous is null, which + // would only happen if install() is called before CrashReporter.install()). previous?.uncaughtException(thread, throwable) + ?: throwable.printStackTrace(System.err) } } @@ -69,7 +76,8 @@ object A11yCrashGuard { while (current != null) { if (current.stackTrace.any { frame -> frame.className.startsWith("androidx.compose.ui.platform.a11y") || - frame.className.startsWith("sun.lwawt.macosx.CAccessible") + // Specific AX bridge inner class present in all known traces for this bug. + frame.className.startsWith("sun.lwawt.macosx.CAccessible\$AXChangeNotifier") } ) { return true @@ -85,7 +93,7 @@ object A11yCrashGuard { super.dispatchEvent(event) } catch (npe: NullPointerException) { if (isComposeA11yNpe(npe)) { - if (warned.compareAndSet(false, true)) { + if (warnedEdt.compareAndSet(false, true)) { System.err.println( "[A11yCrashGuard] Suppressed Compose a11y NPE on macOS " + "(known issue, see GitHub-Store#330 / #640). Further occurrences silenced.",