Skip to content

fix: Three process-abort paths in the Android injecting adapter - #759

Open
AdrianEddy wants to merge 3 commits into
AccessKit:mainfrom
AdrianEddy:android-abort-fixes
Open

fix: Three process-abort paths in the Android injecting adapter#759
AdrianEddy wants to merge 3 commits into
AccessKit:mainfrom
AdrianEddy:android-abort-fixes

Conversation

@AdrianEddy

Copy link
Copy Markdown

Using accesskit_android (embedded-dex) with an InjectingAdapter over a host View whose lifetime tracks AccessibilityManager.isEnabled(), there are three distinct ways to abort the process. All three live in inject.rs / event.rs, and each fix is small — one commit per fix.

1. update_if_active leaks two JNI local references per call (and Drop leaks a third). self.host.upgrade_local(&env) is a bare NewLocalRef, and the Runnable created in post_to_ui_thread is another; jni 0.21's JObject has no Drop glue, so neither is freed — and the adapter's Drop impl upgrades the host the same way. The type documents that its functions make no assumptions about being called from the Android UI thread — but on a thread that is not executing a JNI native method, nothing pops a local frame, so the references accumulate until ART aborts (API ≤ 25, fixed 512-entry table) or grows the table without bound (API 26+). For a toolkit pushing a tree update per accessibility-relevant frame, the fixed table fills in seconds. Fix: register all three references with auto_local.

2. No unwind boundary at the five extern "system" entry points. runCallback, createAccessibilityNodeInfo, findFocus, performAction and onHoverEvent run .unwrap()-based code directly in an extern "system" frame, where Rust aborts on unwind — so any JNI error underneath (a detached view, a genuinely missing method, an OOM) is a SIGABRT rather than a recoverable failure. Nothing in the crate documents an API floor either — getAccessibilityDelegate() in the install closure, for instance, is only public API since 29 (hidden before that), and any lookup that does fail becomes an abort. Fix: a small guard_ffi helper that catches, logs, clears any pending Java exception (returning to Java with one pending rethrows it in the Java caller — inside the UI Looper for runCallback), and returns null / JNI_FALSE. A contained panic poisons the adapter mutex, so later delegate calls fail contained too, and update_if_active skips its work on a poisoned mutex instead of propagating the poison panic into the caller's thread: accessibility degrades instead of the process dying or resuming on half-applied state.

3. send_completed_event calls a method on a null ViewParent. Event raises run from a View.post closure, and Android runs an enqueued Runnable even after the view has been detached — at which point getParent() returns null, jni's call_method rejects the null receiver, and the .unwrap() panics (and, per 2, aborts). Reachable on every "remove the accessibility host view when the screen reader is disabled" edge. Fix: return early — there is nothing to send the event to.

Note that fix 2's catch_unwind is inert in a release profile built with panic = "abort" — which is exactly why fixes 1 and 3, which remove the panic and the leak rather than catching them, are load-bearing on their own.

This PR was generated by Claude

An event raise posted with `View.post` runs even after the host view has
been removed from its parent; `getParent()` is then null and the
`.unwrap()` on the method call panicked out of an `extern "system"`
frame, aborting the process. Drop the event instead — there is nothing
to send it to.
Rust aborts the process when a panic reaches an `extern "system"`
frame, and the code under the five native entry points surfaces every
JNI error as a panic via `.unwrap()` — so a detached host view, a
missing method, or any other JNI error aborted the process. Wrap each
entry point in an unwind boundary that logs, clears any pending Java
exception, and returns null / `JNI_FALSE`. A contained panic poisons
the adapter mutex, so accessibility degrades rather than resuming on
half-applied state; `update_if_active` skips its work when the mutex
is poisoned instead of propagating the poison panic into the caller.
`update_if_active` created two local references per call — the upgraded
host view and the posted `Runnable` — and freed neither, and `Drop`
leaked one more upgraded host reference. The type is documented as
callable from any thread, but on a thread that is not executing a JNI
native method nothing pops a local frame, so the references accumulate
until ART hits its local reference table limit (a hard abort on older
Android versions; unbounded growth on newer ones). Register all three
with `auto_local`.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant