android: make replacement-method dispatch lock-free (arm64 deadlock fix) - #401
Open
Kolektori wants to merge 2 commits into
Open
android: make replacement-method dispatch lock-free (arm64 deadlock fix)#401Kolektori wants to merge 2 commits into
Kolektori wants to merge 2 commits into
Conversation
The art-controller dispatch trampolines (patched quick entrypoints and JIT-compiled hooked methods) took a global GMutex on every native-method dispatch, from arbitrary ART threads in Runnable state. Blocking on that mutex can deadlock against ART thread suspension (GC, thread flips): a thread queued on the futex does not respond to suspend requests, so a SuspendAll or flip started behind it parks every mutator thread and the process wedges. Observed on arm64 (the same workload on x86_64 was unaffected), with and without the ART JIT enabled. Hook install/revert always runs on the JS thread (single writer), so the tables are republished as an immutable singly-linked list: writers prepend a fresh entry and republish the head with a plain aligned pointer store; readers walk the list lock-free. Aligned pointer loads/stores are atomic and observed in order on arm64/x86-64, and each entry is fully initialized before it is linked in, so a reader that sees the new head also sees the whole chain. Retired lists are deliberately leaked, as there is no safe reclamation point without suspending all ART threads. Deleting a mapping (hook revert, rare) republishes a fresh list without the entry. This removes the only blocking locks from the dispatch trampolines and from the GC / thread-flip callbacks (synchronize_replacement_methods, on_leave_gc_concurrent_copying_copying_phase).
A standalone frida script (plus README and an optional Java worker source) that hooks frequently-called native methods and hammers the instrumented dispatch path. On the unfixed bridge the process wedges within minutes: the ANR trace shows several threads blocked in pthread_mutex_lock at the same PC in the dispatch trampoline, entered from art_quick_generic_jni_trampoline. With the lock-free dispatch the cluster does not appear and the run completes. See repro/README.md for the full recipe.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
MR: android — make replacement-method dispatch lock-free
Title:
android: make replacement-method dispatch lock-free (arm64 deadlock fix)Branch:
fix/android-arm64-replacement-dispatch-deadlockCommit:
dde975d(+repro/files)Summary
Installing
.implementationhooks can wedge the target process onarm64: threads pile up blocked on a global
GMutexthat thereplacement-method dispatch takes on ART's hottest dispatch paths, and a
GC
SuspendAllor thread flip starting behind the lock holder freezesevery mutator thread. The process never recovers. x86_64 is unaffected;
disabling the ART JIT reduces but does not eliminate the deadlock.
Root cause
makeArtController()'s dispatch trampolines took a globalGMutexoncode paths that run on arbitrary ART threads in Runnable state:
instrumentArtQuickEntrypoints()patches the prologues ofart_quick_generic_jni_trampoline,art_quick_to_interpreter_bridge,and
art_quick_resolution_trampoline, redirecting them to a trampolinethat calls
find_replacement_method_from_quick_codeon everynative-method dispatch, hooked or not. That function took the
GMutexvia
get_replacement_method.from GC / thread-flip callbacks (
synchronize_replacement_methods,on_leave_gc_concurrent_copying_copying_phase, run-flip hooks).A thread blocked on that futex while in ART Runnable state does not
respond to suspend requests. When a
SuspendAllor thread flip startsbehind a lock holder (or a thread queued behind one), suspension never
completes and every mutator parks at its next suspend point: a
process-wide freeze. arm64 timing makes the race fire; x86_64 mostly
does not.
Fix
Hook install/revert always runs on the JS thread (single writer), so the
methods/replacementstables can be published as an immutablesingly-linked list:
{original, replacement}entry and republishthe head with a plain aligned pointer store. Each entry is fully
initialized before it is linked in, and aligned pointer loads/stores
are atomic and observed in order on arm64/x86-64, so a reader that
sees the new head also sees the whole chain.
list lock-free. Hook counts are typically small (tens of methods), so
the linear scan is negligible on the dispatch path.
without the entry; retired lists are deliberately leaked, as there is
no safe reclamation point without suspending all ART threads.
This removes the only blocking locks from the dispatch trampolines and
from the GC / thread-flip callbacks. The
GMutex, bothGHashTables,and their JS-side allocations are gone entirely (net −4 lines).
Reproduction
A standalone repro ships in
repro/(script + README + optional Javaworker source): it hooks frequently-called native methods, drives GC /
flip activity, and prints a heartbeat with a Java round-trip probe.
npm install frida-java-bridge frida-compile # released npm = unfixed npx frida-compile repro/repro-deadlock-entry.js -o repro-bundled.js frida -U -f com.android.settings -l repro-bundled.jsSee
repro/README.mdfor the full recipe (frida-server setup, optionalJIT-off, expected results and how to confirm the signature).
Expected, unfixed: the process wedges within minutes — heartbeats
stop and the app ANRs. The trace (
kill -3→/data/anr/anr_*, ordebuggerd -b <pid>) shows several threads blocked inpthread_mutex_lockat the same PC in the dispatch trampoline, enteredfrom
art_quick_generic_jni_trampoline:Expected, fixed: no dispatch-mutex cluster; the app's threads stay
responsive; the run completes without a wedge.
Validated on arm64 Android 13/14 emulators: the unfixed bridge wedges
with the same-PC mutex cluster under
art_quick_generic_jni_trampoline;with the fix, no thread is ever observed blocked in the dispatch path.
Notes
same-PC
pthread_mutex_lockcluster under the generic JNI trampoline.entrypoints with
Memory.patchCodewithout suspending ART threads isracy on arm64 (only single-instruction B/BL/NOP/BRK patching is safe
against concurrent execution). Under heavy GC churn with the JIT
enabled, the workload can trip that race and crash instead of
deadlocking — the JIT-off path in the repro avoids this so the
deadlock signature is what you observe. A follow-up could suspend ART
threads during hook install or adopt an ARM-conformant patch protocol.