Rust Android JNI -- RIPDPI
Purpose
Consolidate the five canonical JNI rules that LLM-generated Rust regularly violates on Android targets. The rust-unsafe skill covers the general JNI patterns (catch_unwind, JString::from_raw); this skill is the Android-specific extension that adds thread attachment lifetime, local-ref frame management, JNIEnv async-boundary discipline, hot-path data marshaling, and the VpnService.protect callback wiring.
When to consult
- Authoring or modifying any
#[unsafe(no_mangle)] pub extern "system" fn Java_*export. - Adding a callback from Rust back into Java that requires
JavaVM::attach_current_thread. - Reviewing a diff that touches
ripdpi-android/,ripdpi-tunnel-android/,ripdpi-warp-android/, orripdpi-relay-android/. - Wiring or auditing a
VpnService.protect()callback path.
The five canonical rules
Rule 1 — catch_unwind in every JNI export
Already covered in rust-unsafe and enforced by jni-bridge-verifier. Reproduced here as floor:
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_poyka_ripdpi_ExampleBindings_jniDoWork(
mut env: EnvUnowned<'_>,
_thiz: JObject,
config: JString,
) -> jlong {
env.with_env(|env| -> jni::errors::Result<jlong> {
// body
})
.into_outcome()
.ok_or_throw(env, 0)
}
with_env plus into_outcome is the project's standard wrapper; raw catch_unwind is acceptable only in JNI_OnLoad / JNI_OnUnload where EnvUnowned is unavailable.
Rule 2 — AttachCurrentThread paired with DetachCurrentThread
Tokio worker threads (and std::thread::spawn pthreads) are NOT JVM threads. To call a Java method from such a thread, you must attach. JVM tracks attached threads internally; on thread exit without DetachCurrentThread, JVM logs:
JNI WARNING: native thread exiting without DetachCurrentThread
On Android, the warning is sometimes upgraded to a fatal abort depending on android:debuggable and Android API level. Pattern:
// In a tokio worker that calls back to Java:
let vm = JVM.get().expect("JNI_OnLoad must populate JVM");
let _guard = vm.attach_current_thread()?; // RAII guard
let env = _guard.deref_mut(); // &mut JNIEnv
env.call_method(&listener, "onUpdate", "(I)V", &[update.into()])?;
// `_guard` drop calls DetachCurrentThread.
The jni crate's AttachGuard implements Drop correctly. The trap is in code that calls attach_current_thread() without binding the result — the temporary AttachGuard drops at the end of the expression, detaching before the JNI call runs. Always bind to a let _guard = ... (NOT let _ = ... — that drops immediately).
For long-lived worker threads that make many JNI calls, prefer attach_current_thread_as_daemon so JVM shutdown is not blocked by the attached thread. Use pthread_key_create with a destructor that calls DetachCurrentThread if your worker is owned by pure pthread (not tokio):
// Once at startup:
let mut key: libc::pthread_key_t = 0;
unsafe { libc::pthread_key_create(&mut key, Some(detach_destructor)) };
// detach_destructor calls vm.detach_current_thread()
Rule 3 — Local references are frame-scoped (16-slot limit)
Every env.find_class(), env.get_field(), env.call_method() that returns a JObject consumes a local-reference slot. JVM guarantees only 16 slots per frame; exceeding this is JniLocalReferenceTableOverflow on Android (a hard process abort).
Two cases need explicit with_local_frame wrapping:
// BAD: 32 local refs accumulate; aborts on the 17th.
for client in &accepted_clients {
let s = env.new_string(client.host)?;
listener_method(env, s)?;
}
// GOOD: each iteration gets its own 32-slot frame; refs drop on frame exit.
for client in &accepted_clients {
env.with_local_frame(32, |env| -> jni::errors::Result<()> {
let s = env.new_string(client.host)?;
listener_method(env, s)?;
Ok(())
})?;
}
The 16-slot guarantee is the minimum; some VMs allow more, but Android bionic enforces the minimum strictly. Always wrap loops that touch JNI objects.
Rule 4 — JNIEnv<'a> MUST NOT cross an .await boundary
JNIEnv<'a> is !Send + !Sync. The Rust compiler rejects holding it across an .await in async code that's Send (the default for tokio::spawn). The trap: code paths that LOOK like they don't cross await, but the closure captures JNIEnv and is later sent to tokio::spawn.
Forbidden patterns:
// FORBIDDEN: Box::leak<JNIEnv>. LLM "fix" for a lifetime error.
let env_static: &'static mut JNIEnv = Box::leak(Box::new(env));
// FORBIDDEN: transmute on JNIEnv. Also an LLM "fix".
let env_static: &'static mut JNIEnv = unsafe { std::mem::transmute(env) };
// FORBIDDEN: capturing &mut JNIEnv in a tokio::spawn closure.
tokio::spawn(async move {
env.call_method(...); // compile error or worse, UB through unsafe escape
});
Correct pattern: extract everything you need from JNIEnv synchronously, drop env, then tokio::spawn. If the spawned task must call back into Java, it attaches its own thread via vm.attach_current_thread() inside the spawn body.
fn handle(env: &mut JNIEnv, vm: Arc<JavaVM>, listener: GlobalRef) {
let payload = extract_payload(env); // sync use of env
tokio::spawn(async move {
do_async_work(&payload).await;
let _guard = vm.attach_current_thread().unwrap();
let env = _guard.deref_mut();
env.call_method(&listener, "onComplete", "()V", &[]).ok();
});
}
Rule 5 — JByteArray copies; DirectByteBuffer does not
env.get_byte_array_region and env.get_byte_array_elements COPY the entire byte array between JVM heap and native memory. For hot-path data (per-packet, per-byte), this throughput-couples the JNI boundary to packet rate.
For TUN packets specifically: the canonical RIPDPI pattern is tun_fd ownership transfer at session start. The RawFd crosses JNI exactly ONCE as a jint. Rust then reads/writes the TUN device directly via AsyncFd; packet bytes never cross the JNI boundary again.
If you must transit bytes through JNI (control-plane only, NOT hot path):
// Acceptable for control-plane:
let bytes = env.convert_byte_array(jarray)?; // copies, but rare
process_config(&bytes)?;
// For data plane (avoid where possible):
// Use ByteBuffer.allocateDirect on the Kotlin side. Rust:
let buf: JByteBuffer = jbuffer.into();
let ptr = unsafe { env.get_direct_buffer_address(&buf)? };
let len = unsafe { env.get_direct_buffer_capacity(&buf)? };
let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
// slice points into the JVM-owned DirectByteBuffer; no copy.
Lifetimes: DirectByteBuffer memory is owned by the JVM but persists across the JNI call boundary as long as the Java-side reference exists. The Rust slice is valid until that Java reference goes out of scope.
VpnService.protect callback wiring
Two implementations both satisfy the vpnservice-protect-invariant rule. Choose based on Rust-side context (tokio? raw pthread?).
Option A — UDS + SCM_RIGHTS (preferred when many sockets need protect)
Kotlin runs a UDS listener on a known abstract socket name. Rust opens a connection, sends the fd as ancillary data via sendmsg with SCM_RIGHTS, reads a 1-byte status reply. No JNI call on the Rust hot path.
use nix::sys::socket::*;
fn protect_socket(uds: &mut UnixStream, fd: RawFd) -> io::Result<()> {
let cmsg = [ControlMessage::ScmRights(&[fd])];
sendmsg::<UnixAddr>(
uds.as_raw_fd(),
&[IoSlice::new(b"P")],
&cmsg,
MsgFlags::empty(),
None,
)?;
let mut reply = [0u8; 1];
uds.read_exact(&mut reply)?;
if reply[0] == b'1' { Ok(()) } else { Err(io::Error::other("protect denied")) }
}
The Kotlin side reads the fd, calls VpnService.protect(fd), replies '1' on success.
Option B — Direct JNI callback (simpler when sockets are rare)
fn protect_socket(vm: &Arc<JavaVM>, service: &GlobalRef, fd: RawFd) -> jni::errors::Result<()> {
let _guard = vm.attach_current_thread()?;
let env = _guard.deref_mut();
let result: bool = env
.call_method(service.as_obj(), "protect", "(I)Z", &[fd.into()])?
.z()?;
if result { Ok(()) } else { Err(jni::errors::Error::JavaException) }
}
Cost: attach_current_thread is ~5–15 µs on Android. Acceptable for control-plane sockets (DNS resolver, single SOCKS5 connect); unacceptable for per-flow socket creation at scale.
Tokio thread naming
Worker threads without names appear as Thread-42 in logcat. Always name them:
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name_fn(|| {
static N: AtomicUsize = AtomicUsize::new(0);
format!("ripdpi-tokio-{}", N.fetch_add(1, Ordering::Relaxed))
})
.enable_all()
.build()?;
For non-tokio threads, set the name via pthread_setname_np (Linux/Android) or directly through std::thread::Builder::new().name("...") if you spawn via std::thread.
Audit checklist when reviewing a JNI diff
- Every new
Java_*export usesEnvUnowned::with_env+into_outcomeOR rawcatch_unwind. - Every callback from a tokio task captures
Arc<JavaVM>+GlobalRef, never rawJNIEnv. - Every loop that calls
env.find_class/env.new_string/env.call_methodis wrapped inwith_local_frame(32, ...). - No
Box::leak,mem::transmute, or other escape patterns nearJNIEnvtypes. - New worker thread creation includes
pthread_setname_nporthread_name_fn. - Every outbound socket (non-loopback) is preceded by
protect_socket(fd). Seevpnservice-protect-invariant.mdrule. - Hot-path data (TUN packets) does NOT cross JNI as
JByteArray. Either fd-ownership-transfer orDirectByteBuffer.
Related skills
rust-unsafe—catch_unwind,JString::from_raw,EnvUnowned::with_env, JNI signal handling.rust-async-internals— JNI-to-async bridge canonical pattern,block_onfrom JNI rules.rust-discipline— panic policy, RAII, allocation hot-path rules apply.rust-android-build—.sosize, ELF symbol allowlist, 16 KiB alignment.vpnservice-protect-invariant.md— the rule that drives the protect-callback work in this skill.android-vpn-lifecycle.md— Foreground Service, Doze, thread-naming context.jni-bridge-verifieragent — the automated audit that enforces this skill's checklist.
Source: po4yka/RIPDPI — distributed by TomeVault.