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:
@@ -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
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
Reference in New Issue
Block a user