Skip to content

Commit 358dd10

Browse files
JSKittyclaude
andcommitted
fix(android): a panic can no longer abort the process
Four independent fences, each of which would have contained the launch crash on its own: every JNI entry point runs inside an unwind fence (logged default instead of abort), the panic hook is catch_unwind-wrapped so the reporter can't kill what it reports on, Iroh init waits for the ndk context and refuses in the service-only process, and the three cpal sites check registration before touching the AAudio host. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a0d35e1 commit 358dd10

7 files changed

Lines changed: 674 additions & 571 deletions

File tree

‎src-tauri/src/android/miniapp_jni.rs‎

Lines changed: 586 additions & 558 deletions
Large diffs are not rendered by default.

‎src-tauri/src/android/utils.rs‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,29 @@ fn try_android_context() -> Option<AndroidContext> {
1212
std::panic::catch_unwind(ndk_context::android_context).ok()
1313
}
1414

15+
/// Whether tao has registered the ndk context yet. Gate for code whose
16+
/// DEPENDENCIES call `ndk_context::android_context()` unguarded (iroh's
17+
/// hickory-resolver reads system DNS with it and the panic aborts the
18+
/// process): they must not run before registration, and never in the
19+
/// Activity-less service-only process, where it never comes.
20+
pub fn context_registered() -> bool {
21+
try_android_context().is_some()
22+
}
23+
24+
/// Unwind fence for JNI entry points. A Rust panic crossing an `extern "C"`
25+
/// boundary is a process abort; inside the fence it becomes a logged default
26+
/// instead. The default must be safe for the Java caller (unit, or a null
27+
/// object the Kotlin side treats as failure).
28+
pub fn jni_fence<T>(name: &str, default: impl FnOnce() -> T, body: impl FnOnce() -> T) -> T {
29+
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) {
30+
Ok(v) => v,
31+
Err(_) => {
32+
vector_core::log_warn!("[JNI] {} panicked — returned default instead of aborting", name);
33+
default()
34+
}
35+
}
36+
}
37+
1538
/// Standard buffer size for reading streams
1639
pub const STREAM_BUFFER_SIZE: i32 = 8192;
1740

‎src-tauri/src/audio.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,14 @@ fn get_device_sample_rate() -> Result<u32, String> {
9494
return Ok(cached);
9595
}
9696

97+
// cpal's AAudio host reads device params through `ndk_context`, which
98+
// panics unregistered (service-only process, or pre-registration) — and a
99+
// panic here can abort. Refuse with an error instead.
100+
#[cfg(target_os = "android")]
101+
if !crate::android::utils::context_registered() {
102+
return Err("Audio unavailable: Android context not registered".to_string());
103+
}
104+
97105
// Query the device
98106
let host = cpal::default_host();
99107
let device = host

‎src-tauri/src/audio_engine.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,14 @@ impl AudioEngine {
181181
}
182182

183183
fn create() -> Result<Self, String> {
184+
// cpal's AAudio host reads device params through `ndk_context`, which
185+
// panics unregistered (service-only process, or pre-registration) — and a
186+
// panic here can abort. Refuse with an error instead.
187+
#[cfg(target_os = "android")]
188+
if !crate::android::utils::context_registered() {
189+
return Err("Audio unavailable: Android context not registered".to_string());
190+
}
191+
184192
let host = cpal::default_host();
185193
let device = host
186194
.default_output_device()

‎src-tauri/src/lib.rs‎

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -131,21 +131,27 @@ pub fn run() {
131131
// Install a panic hook that logs the crash before the process dies.
132132
// Without this, panics in spawned tasks vanish silently.
133133
std::panic::set_hook(Box::new(|info| {
134-
let backtrace = std::backtrace::Backtrace::force_capture();
135-
let secs = std::time::SystemTime::now()
136-
.duration_since(std::time::UNIX_EPOCH)
137-
.unwrap_or_default()
138-
.as_secs();
139-
let msg = format!("[PANIC {:02}:{:02}:{:02}Z] {info}\n\nBacktrace:\n{backtrace}\n",
140-
(secs / 3600) % 24, (secs / 60) % 60, secs % 60);
141-
eprintln!("{msg}");
142-
// Append to log file (shared with log_error!)
143-
if let Ok(data_dir) = account_manager::get_app_data_dir() {
134+
// A panic ESCAPING a panic hook aborts the process on the spot, so the
135+
// reporter must never be able to kill what it reports on: everything
136+
// here is catch_unwind-wrapped, and stderr uses the error-swallowing
137+
// write (eprintln! itself panics when stderr is gone).
138+
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
139+
let backtrace = std::backtrace::Backtrace::force_capture();
140+
let secs = std::time::SystemTime::now()
141+
.duration_since(std::time::UNIX_EPOCH)
142+
.unwrap_or_default()
143+
.as_secs();
144+
let msg = format!("[PANIC {:02}:{:02}:{:02}Z] {info}\n\nBacktrace:\n{backtrace}\n",
145+
(secs / 3600) % 24, (secs / 60) % 60, secs % 60);
144146
use std::io::Write;
145-
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(data_dir.join("vector.log")) {
146-
let _ = write!(f, "{}\n", &msg);
147+
let _ = writeln!(std::io::stderr(), "{msg}");
148+
// Append to log file (shared with log_error!)
149+
if let Ok(data_dir) = account_manager::get_app_data_dir() {
150+
if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(data_dir.join("vector.log")) {
151+
let _ = write!(f, "{}\n", &msg);
152+
}
147153
}
148-
}
154+
}));
149155
}));
150156

151157
// Harden against memory inspection and debugger attachment (release builds only).

‎src-tauri/src/miniapps/realtime.rs‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -901,6 +901,28 @@ impl RealtimeManager {
901901
// the frontend obtains once-per-session user consent before launching
902902
// a realtime-capable Mini App while Tor is enabled.
903903

904+
// Android: iroh's resolver stack (hickory, netdev) reads system config
905+
// through `ndk_context`, and hickory's reader ABORTS the process when
906+
// the context isn't registered yet — tao 0.35 registers it later in
907+
// startup than 0.34 did, so a boot-time peer-advert preconnect can
908+
// race into the window. Wait it out; the service-only process never
909+
// registers one, so cap the wait and refuse there (it has no UI for
910+
// a realtime session anyway).
911+
#[cfg(target_os = "android")]
912+
{
913+
let mut registered = crate::android::utils::context_registered();
914+
for _ in 0..50 {
915+
if registered { break; }
916+
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
917+
registered = crate::android::utils::context_registered();
918+
}
919+
if !registered {
920+
return Err(anyhow::anyhow!(
921+
"Android context never registered — refusing to start Iroh (service-only process?)"
922+
));
923+
}
924+
}
925+
904926
// Slow path: write lock, double-check, initialize
905927
let mut guard = self.iroh.write().await;
906928
if let Some(ref iroh) = *guard {

‎src-tauri/src/voice.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,14 @@ impl AudioRecorder {
125125
let (tx, rx) = mpsc::channel();
126126
*self.stop_tx.lock().unwrap() = Some(tx);
127127

128+
// cpal's AAudio host reads device params through `ndk_context`, which
129+
// panics unregistered (service-only process, or pre-registration) — and a
130+
// panic here can abort. Refuse with an error instead.
131+
#[cfg(target_os = "android")]
132+
if !crate::android::utils::context_registered() {
133+
return Err("Audio unavailable: Android context not registered".to_string());
134+
}
135+
128136
let host = cpal::default_host();
129137
let device = host.default_input_device().ok_or("No input device found")?;
130138

0 commit comments

Comments
 (0)