Attach the runtime threads to the JVM, or nothing scans

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-05 20:26:59 +02:00
co-authored by Claude Opus 5
parent aa99b83c40
commit a4670f4992
3 changed files with 81 additions and 1 deletions
+68
View File
@@ -48,6 +48,13 @@ pub extern "system" fn Java_paris_tourolle_bikecontrol_MainActivity_initBtleplug
env: JNIEnv, env: JNIEnv,
_class: JClass, _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) { match btleplug::platform::init(&env) {
Ok(()) => { Ok(()) => {
READY.store(true, Ordering::Release); 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. /// Whether `initBtleplug` has succeeded.
/// ///
/// This has to be checked before *every* call into btleplug, because failing /// This has to be checked before *every* call into btleplug, because failing
+7 -1
View File
@@ -603,7 +603,13 @@ async fn scan_loop(mut on: watch::Receiver<bool>, tx: watch::Sender<ScanSnapshot
generation, generation,
}, },
Err(e) => { Err(e) => {
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 { ScanSnapshot {
devices: Vec::new(), devices: Vec::new(),
error: Some(e.to_string()), error: Some(e.to_string()),
+6
View File
@@ -52,6 +52,12 @@ fn init_tracing() {
pub fn run() { pub fn run() {
init_tracing(); 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() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.manage(AppState::new()) .manage(AppState::new())