Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,53 +14,95 @@ 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 {
// 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

// 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 (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.",
)
}
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)
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

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") ||
// Specific AX bridge inner class present in all known traces for this bug.
frame.className.startsWith("sun.lwawt.macosx.CAccessible\$AXChangeNotifier")
}
) {
return true
}
current = current.cause
}
return false
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

private class FilteringEventQueue : EventQueue() {
override fun dispatchEvent(event: AWTEvent) {
try {
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). Further occurrences silenced.",
"(known issue, see GitHub-Store#330 / #640). Further occurrences silenced.",
)
}
return
}
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
}
}
}