From a4670f499286c5037254051288891e7d09e65ab4 Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 5 Aug 2026 20:26:59 +0200 Subject: [PATCH] Attach the runtime threads to the JVM, or nothing scans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every scan on the phone failed with "bluetooth error: JNI call failed", which is the entire symptom: jni's Display for JniCall drops the source that says what actually went wrong. It was ThreadDetached. droidplug reaches the JVM through JavaVM::get_env(), which does not attach — it fails outright on any thread the JVM has never seen. Every BLE call in this app is made from a Tauri task, and Tauri's default runtime spawns plain Rust worker threads, so on Android no BLE call could ever have worked. This was invisible until it ran on hardware: the desktop build shares the code and does not care. Attaching inside the tasks would not have fixed it. A Tokio task can move to another worker at any .await, so the thread that starts a scan is not necessarily the one that polls it next — the attachment has to belong to the threads, not the work. on_thread_start is the hook that gets that right, and it covers the blocking pool too. Permanent rather than scoped, because a scoped attachment detaches at the end of the guard, which for a worker thread means after the first task it runs. Ordering is load-bearing at both ends. The JVM is stashed in initBtleplug, which runs before the super chain that starts us, so it is there when the runtime is built; and the runtime is installed before tauri::Builder, because async_runtime::set only affects later spawns. The scan log now carries the Debug form as well as Display. The chain read `Bluetooth(Other(JniCall(ThreadDetached)))` all along and would have named this in the first minute rather than the last. Co-Authored-By: Claude Opus 5 --- src-tauri/src/android.rs | 68 ++++++++++++++++++++++++++++++++++++++++ src-tauri/src/devices.rs | 8 ++++- src-tauri/src/lib.rs | 6 ++++ 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/android.rs b/src-tauri/src/android.rs index 600bbe9..98327f0 100644 --- a/src-tauri/src/android.rs +++ b/src-tauri/src/android.rs @@ -48,6 +48,13 @@ pub extern "system" fn Java_paris_tourolle_bikecontrol_MainActivity_initBtleplug env: JNIEnv, _class: JClass, ) { + // Stash the VM on the earliest call that has one. `nativeSetActivity` also + // sets it, but this runs first and `install_async_runtime` needs it before + // the activity has finished being constructed. + if let Ok(vm) = env.get_java_vm() { + let _ = JVM.set(vm); + } + match btleplug::platform::init(&env) { Ok(()) => { READY.store(true, Ordering::Release); @@ -57,6 +64,67 @@ pub extern "system" fn Java_paris_tourolle_bikecontrol_MainActivity_initBtleplug } } +/// Give Tauri an async runtime whose threads can talk to Java. +/// +/// This is not an optimisation — without it *nothing* BLE works on Android, and +/// it took a device to find out. droidplug reaches the JVM through +/// `JavaVM::get_env()`, which does **not** attach: it returns +/// `JniCall(ThreadDetached)` on any thread the JVM has never seen. Every BLE +/// call in this app is made from a Tauri task, and Tauri's default runtime +/// spawns plain Rust worker threads, so every single scan failed with +/// `bluetooth error: JNI call failed` — an error that says nothing whatsoever +/// about threads and is why this cost an evening. +/// +/// Attaching inside the tasks would not fix it. A Tokio task can be moved to a +/// different worker thread at any `.await`, so the thread that starts a scan is +/// not necessarily the one that polls it next; the attachment has to belong to +/// the *threads*, not the work. `on_thread_start` is the only hook that gets +/// that right, and it covers the blocking pool as well as the workers. +/// +/// Permanent, not scoped: a scoped attachment detaches at the end of the guard, +/// which for a worker thread means detaching after the first task it runs. +/// +/// Must be called before anything spawns — `tauri::async_runtime::set` only +/// affects later work, so a task spawned before this lands on the default +/// runtime and fails exactly as before. +pub fn install_async_runtime() { + let Some(vm) = JVM.get() else { + // initBtleplug runs from MainActivity.onCreate before the super chain + // that starts us, so this is unreachable in practice — but if it ever + // happens, every BLE call is about to fail and the reason must not be + // a mystery a second time. + tracing::error!("no JavaVM yet; BLE will fail with ThreadDetached on every call"); + return; + }; + + // `&'static JavaVM` out of the OnceLock, rather than the raw pointer: the + // pointer is not `Send`, and `JavaVM` is exactly the wrapper that asserts + // it is safe to share. + let runtime = match tokio::runtime::Builder::new_multi_thread() + .enable_all() + .thread_name("bikecontrol-rt") + .on_thread_start(move || { + if let Err(e) = vm.attach_current_thread_permanently() { + tracing::error!("could not attach runtime thread to the JVM: {e}"); + } + }) + .build() + { + Ok(runtime) => runtime, + Err(e) => { + tracing::error!("could not build the JVM-attached runtime: {e}"); + return; + } + }; + + tauri::async_runtime::set(runtime.handle().clone()); + // The handle Tauri now holds does not keep the runtime alive, and dropping + // it here would shut down every worker before the first task. It lives as + // long as the process by design. + Box::leak(Box::new(runtime)); + tracing::info!("async runtime installed with JVM-attached threads"); +} + /// Whether `initBtleplug` has succeeded. /// /// This has to be checked before *every* call into btleplug, because failing diff --git a/src-tauri/src/devices.rs b/src-tauri/src/devices.rs index 298bf57..87b9bff 100644 --- a/src-tauri/src/devices.rs +++ b/src-tauri/src/devices.rs @@ -603,7 +603,13 @@ async fn scan_loop(mut on: watch::Receiver, tx: watch::Sender { - tracing::warn!(error = %e, "scan failed"); + // Debug as well as Display, because the useful half of a BLE + // failure is usually in the source chain that Display drops. + // "bluetooth error: JNI call failed" was the *entire* symptom of + // a detached-thread bug; the Debug form said + // `Bluetooth(Other(JniCall(ThreadDetached)))` and would have + // named it outright. + tracing::warn!(error = %e, cause = ?e, "scan failed"); ScanSnapshot { devices: Vec::new(), error: Some(e.to_string()), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index e58171a..d7a9710 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -52,6 +52,12 @@ fn init_tracing() { pub fn run() { init_tracing(); + // Before anything spawns: every BLE call is made from a Tauri task, and on + // Android a task running on a thread the JVM has never seen cannot reach + // droidplug at all. See `android::install_async_runtime`. + #[cfg(target_os = "android")] + android::install_async_runtime(); + tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) .manage(AppState::new())