From 923e9402e5775630c718c87bbbffb2707509a38c Mon Sep 17 00:00:00 2001 From: May Knott Date: Sun, 24 May 2026 02:06:38 +0330 Subject: [PATCH 1/2] fix(android): bound JNI string local references Android JNI entry points return string payloads for version checks, log drains, update checks, SNI probes, live stats, and pipeline diagnostics. Several of those methods are invoked repeatedly by the Kotlin UI while the proxy is running, so each path should keep JNI local-reference ownership explicit and bounded even when future payload construction adds intermediate Java objects. Add a shared string_to_jstring helper that builds returned Java strings inside an explicit local frame with with_local_frame_returning_local. The frame preserves only the returned jstring for the JVM caller and releases temporary local handles before the native method exits. Allocation or frame setup failures continue to return a null jstring, preserving the existing failure contract. Route every Android string-returning native entry point through the helper without changing payload contents, method names, signatures, threading behavior, or Kotlin call sites. This keeps repeated telemetry and diagnostics polling from relying on the implicit native-method frame and gives the JNI boundary a single audited conversion path. --- src/android_jni.rs | 35 +++++++++++++++++++++-------------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/android_jni.rs b/src/android_jni.rs index 7bccfdd2..8a1fb82b 100644 --- a/src/android_jni.rs +++ b/src/android_jni.rs @@ -20,7 +20,7 @@ use std::path::PathBuf; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock}; -use jni::objects::{JClass, JString}; +use jni::objects::{JClass, JObject, JString}; use jni::sys::{jboolean, jlong, jstring, JNI_FALSE, JNI_TRUE}; use jni::JNIEnv; use tokio::runtime::Runtime; @@ -145,6 +145,15 @@ fn jstring_to_string(env: &mut JNIEnv, s: &JString) -> String { .unwrap_or_else(|_| String::new()) } +/// Helper: String -> jstring, returning null on allocation failure. +fn string_to_jstring(env: &mut JNIEnv, value: &str) -> jstring { + let obj: jni::errors::Result = env.with_local_frame_returning_local(4, |env| { + env.new_string(value).map(JObject::from) + }); + obj.map(|s| s.into_raw() as jstring) + .unwrap_or(std::ptr::null_mut()) +} + fn safe R + std::panic::UnwindSafe, R>(default: R, f: F) -> R { std::panic::catch_unwind(f).unwrap_or(default) } @@ -327,11 +336,11 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_exportCa( /// `Native.version()` -> String. Trivial smoke test for the JNI linkage. #[no_mangle] pub extern "system" fn Java_com_therealaleph_mhrv_Native_version<'a>( - env: JNIEnv<'a>, + mut env: JNIEnv<'a>, _class: JClass, ) -> jstring { let v = env!("CARGO_PKG_VERSION"); - env.new_string(v).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut()) + string_to_jstring(&mut env, v) } /// `Native.drainLogs()` -> String. Returns the full ring buffer as a single @@ -340,7 +349,7 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_version<'a>( /// for display. Empty string when there's nothing to read. #[no_mangle] pub extern "system" fn Java_com_therealaleph_mhrv_Native_drainLogs<'a>( - env: JNIEnv<'a>, + mut env: JNIEnv<'a>, _class: JClass, ) -> jstring { let out = safe(String::new(), AssertUnwindSafe(|| { @@ -351,7 +360,7 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_drainLogs<'a>( let lines: Vec = g.drain(..).collect(); lines.join("\n") })); - env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut()) + string_to_jstring(&mut env, &out) } /// `Native.checkUpdate()` -> String. Runs the same `update_check::check` @@ -367,7 +376,7 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_drainLogs<'a>( /// Blocking — hit from a background dispatcher. #[no_mangle] pub extern "system" fn Java_com_therealaleph_mhrv_Native_checkUpdate<'a>( - env: JNIEnv<'a>, + mut env: JNIEnv<'a>, _class: JClass, ) -> jstring { let result_json = safe( @@ -383,9 +392,7 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_checkUpdate<'a>( update_check_to_json(&outcome) }), ); - env.new_string(result_json) - .map(|s| s.into_raw()) - .unwrap_or(std::ptr::null_mut()) + string_to_jstring(&mut env, &result_json) } fn update_check_to_json(u: &crate::update_check::UpdateCheck) -> String { @@ -455,7 +462,7 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_testSni<'a>( _ => r#"{"ok":false,"error":"unknown"}"#.to_string(), } })); - env.new_string(result_json).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut()) + string_to_jstring(&mut env, &result_json) } /// `Native.statsJson(long handle)` -> String. Returns a JSON blob with the @@ -466,7 +473,7 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_testSni<'a>( /// timer to render the "Usage today (estimated)" card. #[no_mangle] pub extern "system" fn Java_com_therealaleph_mhrv_Native_statsJson<'a>( - env: JNIEnv<'a>, + mut env: JNIEnv<'a>, _class: JClass, handle: jlong, ) -> jstring { @@ -483,7 +490,7 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_statsJson<'a>( }; f.snapshot_stats().to_json() })); - env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut()) + string_to_jstring(&mut env, &out) } /// `Native.pipelineDebugJson()` -> String. Snapshot of pipeline debug state: @@ -491,13 +498,13 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_statsJson<'a>( /// Temporary — for the debug overlay. #[no_mangle] pub extern "system" fn Java_com_therealaleph_mhrv_Native_pipelineDebugJson<'a>( - env: JNIEnv<'a>, + mut env: JNIEnv<'a>, _class: JClass, ) -> jstring { let out = safe(String::new(), AssertUnwindSafe(|| { crate::tunnel_client::pipeline_debug::to_json() })); - env.new_string(out).map(|s| s.into_raw()).unwrap_or(std::ptr::null_mut()) + string_to_jstring(&mut env, &out) } // --------------------------------------------------------------------------- From a3f349533bff16999b50c787925aaf37f739fd6a Mon Sep 17 00:00:00 2001 From: May Knott Date: Sun, 24 May 2026 02:51:39 +0330 Subject: [PATCH 2/2] fix(android): size proxy runtime workers by device capacity Android startup built the long-lived proxy Tokio runtime with a fixed four-worker pool on every device. That is unnecessarily large on low-core phones, tablets in power-saving mode, and embedded Android targets, while still being capped enough that larger devices do not need more than a small bounded pool for this local proxy workload. Add a dedicated worker-count helper for the Android proxy runtime. It reads the platform's available parallelism when the runtime starts, falls back to the minimum proxy-safe worker count when the value is unavailable, and clamps the result between two and four workers. The lower bound keeps accept, tunnel, stats, and shutdown work from competing on a single worker; the upper bound preserves the previous maximum and avoids creating a wider scheduler on constrained mobile CPUs. Use the computed value when constructing the runtime and log the selected worker count once during startup. One-shot JNI runtimes used for probes and certificate operations remain current-thread runtimes, and no Kotlin method signatures, config fields, proxy routing behavior, or Android lifecycle contracts change. --- src/android_jni.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/android_jni.rs b/src/android_jni.rs index 8a1fb82b..efcbf4f6 100644 --- a/src/android_jni.rs +++ b/src/android_jni.rs @@ -70,6 +70,8 @@ extern "C" { const ANDROID_LOG_INFO: i32 = 4; const LOG_RING_CAP: usize = 500; +const ANDROID_RUNTIME_MIN_WORKERS: usize = 2; +const ANDROID_RUNTIME_MAX_WORKERS: usize = 4; fn log_ring() -> &'static Mutex> { static RING: OnceLock>> = OnceLock::new(); @@ -158,6 +160,13 @@ fn safe R + std::panic::UnwindSafe, R>(default: R, f: F) -> R { std::panic::catch_unwind(f).unwrap_or(default) } +fn android_runtime_worker_threads() -> usize { + std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(ANDROID_RUNTIME_MIN_WORKERS) + .clamp(ANDROID_RUNTIME_MIN_WORKERS, ANDROID_RUNTIME_MAX_WORKERS) +} + /// Build a throwaway tokio runtime for one-shot blocking calls from JNI. /// Small, single-worker — sufficient for probes and cert ops. fn one_shot_runtime() -> Option { @@ -210,8 +219,9 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_startProxy( // Try to build the runtime first — if allocation fails we want to // know before spinning up anything stateful. + let worker_threads = android_runtime_worker_threads(); let rt = match tokio::runtime::Builder::new_multi_thread() - .worker_threads(4) + .worker_threads(worker_threads) .enable_all() .thread_name("mhrv-worker") .build() @@ -222,6 +232,7 @@ pub extern "system" fn Java_com_therealaleph_mhrv_Native_startProxy( return 0i64; } }; + tracing::info!("android: tokio runtime worker_threads={}", worker_threads); let base = crate::data_dir::data_dir(); let mitm = match MitmCertManager::new_in(&base) {