Skip to content

Stop leaving JNI exceptions pending across the native boundary - #853

Merged
JingMatrix merged 1 commit into
masterfrom
jni-pending-exceptions
Aug 3, 2026
Merged

Stop leaving JNI exceptions pending across the native boundary#853
JingMatrix merged 1 commit into
masterfrom
jni-pending-exceptions

Conversation

@JingMatrix

@JingMatrix JingMatrix commented Aug 2, 2026

Copy link
Copy Markdown
Owner

JNI has a rule that is easy to forget: when a Java method throws, the exception does not become a C++ exception. It stays pending on the thread, and the runtime is entitled to abort at the next transition rather than let you carry on. So every call into Java, and every lookup that can fail, owes an answer to "did that throw" — and six files never asked.

The worst of them is FindAndCall, which hands a whole process to the framework's Java entry and then inspected nothing. A throwing entry left that process without Xposed, while the line printed immediately afterwards said the framework had been injected. It now reports the failure and returns whether the call arrived, and both callers say which happened. The rest are smaller versions of the same thing: two lookups in resources_hook returned JNI_FALSE to Java with NoSuchMethodError still pending, so a caller that asked for a boolean got a throw instead; RegisterNatives, LogcatMonitor's refreshFd lookup and dex2oat's string read did the same on their failure paths; and the obfuscation map builder returned null on a failed FindClass without clearing, then fed two unchecked method ids to NewObject.

Most of this is not new code but the lsplant wrappers we already have. They clear the exception, log the Java stack behind it, and hand back scoped references — which incidentally disposes of a local reference the obfuscation map leaked per entry. An explicit check survives only where the caller has to know what happened, because a wrapper clears the exception before anyone can ask.

Two decisions worth recording. The stack is rendered with Log.getStackTraceString rather than ExceptionDescribe, because the latter writes to stderr and a process forked from the zygote has nowhere for stderr to go — the trace would simply vanish. And SetAllowUnload(false) stays unconditional: the ART and JNI hooks are installed before the entry runs and their trampolines point into this library, so a failed entry is no reason to let it be unloaded.

hook_bridge is deliberately untouched. It implements Method.invoke semantics and has to leave a target's exception pending so it can wrap it in InvocationTargetException.

A Java method that throws does not unwind into C++. The exception is left
pending on the thread, and the runtime asserts on that at the next
transition: on a device, a throwing framework entry aborted system_server
with "No pending exception expected" and the phone boot looped. CheckJNI
is not needed for this; the assert is always on.

Six places let that happen. FindAndCall handed the framework entry to Java
and looked at nothing afterwards, so the abort was all anyone got, while
the log line above it still said the framework had been injected. Two
lookups in resources_hook returned JNI_FALSE to Java with
NoSuchMethodError pending, so a caller that asked for a boolean got a
throw. RegisterNatives, LogcatMonitor's refreshFd lookup and dex2oat's
string read did the same on their failure paths, and the obfuscation map
builder returned null on a failed FindClass without clearing, then fed two
unchecked method ids to NewObject.

Most of them are now the lsplant JNI wrappers, which clear the exception,
log the Java stack behind it, and return scoped references -- that last
part also releases the local reference the obfuscation map leaked per
entry. Where the caller has to know the outcome, the check stays explicit,
because a wrapper clears the exception before anyone can ask.

The trace is rendered through Log.getStackTraceString rather than
ExceptionDescribe. ExceptionDescribe writes to stderr, which in a process
forked from the zygote goes nowhere: measured on a device, it produced no
output at all, which would have traded an aborting-but-informative
tombstone for a survivable process and no stack.

SetAllowUnload(false) deliberately stays unconditional: the ART and JNI
hooks are installed before the entry runs and their trampolines point into
this library, so a failed entry is not a reason to let it be unloaded.

hook_bridge is untouched. It implements Method.invoke semantics and has to
leave a target's exception pending so it can wrap it in
InvocationTargetException.
@JingMatrix
JingMatrix force-pushed the jni-pending-exceptions branch from b71e6fb to 4e6cc3e Compare August 2, 2026 21:49
@JingMatrix
JingMatrix merged commit 92f3e60 into master Aug 3, 2026
1 check passed
@JingMatrix
JingMatrix deleted the jni-pending-exceptions branch August 7, 2026 19:32
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