Skip to content
Error Handling and Panics: Keeping Rust Crashes Out of Your Android App
rust-native

Error Handling and Panics: Keeping Rust Crashes Out of Your Android App

By David Cruz Anaya, Senior Mobile & Applied AI Engineer (Android, Kotlin Multiplatform, product AI)

Updated 11 September 20269 min read
AndroidRustNDKJNIError HandlingPanics
Share:

Rust + NDK on Android, Part 8 of 15.This series teaches Android developers how to ship Rust in production apps, no prior Rust required. Hub: Rust on Android: How to Use It, How to Debug It, and Why It's Worth It. Previous: Memory Management Across JNI: Ownership, Box::into_raw, Global Refs, and Leaks. Next: Threading and Async: Tokio, Kotlin Coroutines, and Calling Back Into Kotlin from Rust (coming soon).

Nothing on the Kotlin side can catch a Rust panic. Once a panic reaches the JNI boundary, the process is gone: since Rust 1.81, a panic that tries to unwind out of an extern "system" function aborts instead of drifting into undefined behavior (Rust Blog, Announcing Rust 1.81.0, 2024). On Android that abort is a native crash: the user watches the app close mid-tap, and you get a SIGABRT tombstone. No try block ever sees it, because there is no exception, only a dying process.

This part builds three layers of defense: a panic hook that writes the message to logcat before the process dies, a catch_unwind firewall that turns escaped bugs into ordinary Kotlin exceptions, and Result mapped to typed exceptions for every failure you can predict. This assumes the toolchain from Part 1, and the ownership rules from Part 7 apply to everything below.

Key Takeaways

  • A panic that escapes a JNI entry point aborts the whole app. Since Rust 1.81 this is guaranteed for extern "system" functions, no longer undefined behavior (Rust Blog, 2024).
  • Rust prints panic messages to stderr, and Android throws stderr away. A panic hook plus android_logger gets the message into logcat before the abort.
  • std::panic::catch_unwind at the boundary converts a panic into a thrown Java exception. It only works while panic = "unwind" is in effect, so never set panic = "abort" in a profile that relies on the firewall.
  • Expected failures should never panic. Return Result, translate Err into a typed Kotlin exception with throw_new, or let UniFFI generate the mapping as in Part 3.

What a Rust panic under JNI does to your Android app

Rust has no exceptions, it has two error channels. Result<T, E> is a value, the same idea as a Kotlin sealed class with a success and a failure case. A panic is for bugs, the situations check() would catch in Kotlin, and it unwinds the stack frame by frame. The trouble starts when the unwinding reaches a frame that is not Rust: the JNI machinery inside ART is C++, and unwinding through foreign frames is undefined behavior (The Rustonomicon, FFI). Rust 1.81 closed that trap door by aborting the process the moment a panic tries to escape an extern "system" function. The C-unwind ABI exists for C++ interop, but ART cannot do anything useful with a Rust unwind, so under JNI it changes nothing.

Let’s break it on purpose. The demo crate lives at rust/errors; its rust/errors/Cargo.toml declares the usual cdylib plus logging:

[package]
name = "errors"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
jni = "0.21"
log = "0.4"
android_logger = "0.15"

Here is the top of rust/errors/src/lib.rs, every import the file will need, plus a parser ported from a CLI tool. On the desktop, panicking on bad input just prints and exits, so plenty of real Rust is written like this:

use std::fmt;
use std::panic::catch_unwind;

use android_logger::Config;
use jni::objects::{JObject, JString};
use jni::sys::jlong;
use jni::JNIEnv;
use log::LevelFilter;

// Ported from a CLI tool, where a panic on bad input was acceptable.
pub fn parse_cents_or_panic(input: &str) -> i64 {
    let trimmed = input.trim();
    assert!(!trimmed.is_empty(), "amount is empty");
    let (units, decimals) = trimmed.split_once('.').unwrap_or((trimmed, ""));
    assert!(decimals.len() <= 2, "more than two decimal places");
    let digit = |c: char| -> i64 {
        match c.to_digit(10) {
            Some(d) => d as i64,
            None => panic!("unexpected character {c:?} in amount"),
        }
    };
    let mut cents = units.chars().fold(0i64, |acc, c| acc * 10 + digit(c));
    cents *= 100;
    let mut scale = 10;
    for c in decimals.chars() {
        cents += digit(c) * scale;
        scale /= 10;
    }
    cents
}

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_ErrorsBridge_parseCentsNaive<'local>(
    mut env: JNIEnv<'local>,
    _this: JObject<'local>,
    amount: JString<'local>,
) -> jlong {
    let input: String = env
        .get_string(&amount)
        .map(Into::into)
        .unwrap_or_default();
    parse_cents_or_panic(&input)
}

The Kotlin bridge at app/src/main/java/dev/davthecoder/rustseries/ErrorsBridge.kt declares everything this post builds; nativeInit is explained in the next section:

package dev.davthecoder.rustseries

class AmountParseException(message: String) : IllegalArgumentException(message)

object ErrorsBridge {
    init {
        System.loadLibrary("errors")
        nativeInit()
    }

    private external fun nativeInit()
    external fun parseCentsNaive(amount: String): Long
    external fun parseCentsGuarded(amount: String): Long
    external fun parseCents(amount: String): Long
}

Call ErrorsBridge.parseCentsNaive("12.50") and every test passes. Then a user in Berlin types 12,50, decimal comma, the digit closure panics, and the process aborts. The tombstone, trimmed:

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
Build fingerprint: 'google/panther/panther:15/...'
ABI: 'arm64'
pid: 18123, tid: 18123, name: oder.rustseries  >>> dev.davthecoder.rustseries <<<
signal 6 (SIGABRT), code -1 (SI_QUEUE), fault addr --------
    #00 pc 000000000005f1a4  /apex/com.android.runtime/lib64/bionic/libc.so (abort+164)
    #01 pc 0000000000082f10  .../lib/arm64/liberrors.so (std::panicking::rust_panic)
    #02 pc 0000000000081a30  .../lib/arm64/liberrors.so (core::panicking::panic_fmt)
    #03 pc 000000000007c644  .../lib/arm64/liberrors.so (errors::parse_cents_or_panic)
    #04 pc 000000000007c8b0  .../lib/arm64/liberrors.so (Java_dev_davthecoder_rustseries_ErrorsBridge_parseCentsNaive)
    #05 pc 00000000003782f8  /apex/com.android.art/lib64/libart.so (art_quick_generic_jni_trampoline+152)

Reading it: signal 6 means the process asked bionic to abort itself. The 15 character thread name is the tail of your package name. Between abort and your own function sit Rust’s panic internals, then the JNI entry point, then ART’s trampoline. Turning raw addresses into these names is the ndk-stack workflow from the hub’s debugging section (Android Developers, ndk-stack).

Now notice what is missing: the panic message. It went to stderr, and Android discards stderr. The stack says where, but the why never left the process.

A panic hook that reaches logcat before the process dies

The fix is one function, called once when the library loads, in rust/errors/src/lib.rs:

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_ErrorsBridge_nativeInit(
    _env: JNIEnv,
    _this: JObject,
) {
    android_logger::init_once(
        Config::default()
            .with_max_level(LevelFilter::Info)
            .with_tag("rusterrors"),
    );
    let previous = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        log::error!("rust panic: {info}");
        previous(info);
    }));
    log::info!("liberrors loaded, panic hook installed");
}

The android_logger crate routes the standard log macros to logcat, and init_once makes repeated calls harmless. The hook chains the previous one instead of replacing it, so cargo test on your laptop still prints panics to the terminal. The ErrorsBridge init block calls nativeInit right after System.loadLibrary, the earliest moment it can run.

With the hook installed, the same decimal comma crash now leaves this in adb logcat -s rusterrors right before the abort:

E/rusterrors: rust panic: panicked at rust/errors/src/lib.rs:19:21:
    unexpected character ',' in amount

File, line, and the actual message: the tombstone said where the app died, this line says why. A panic hook runs at the moment of panic, before any unwinding, so it also fires for panics the next section catches. Install it in every crate you ship; it is the highest value change in this post.

A catch_unwind firewall at the JNI boundary

Logging the panic is diagnosis; containing it is std::panic::catch_unwind, which runs a closure and returns Err with the panic payload instead of unwinding further. At the JNI boundary it becomes a firewall: the panic still means a bug, but the bug costs one failed call instead of the process. The guarded version, also in rust/errors/src/lib.rs:

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_ErrorsBridge_parseCentsGuarded<'local>(
    mut env: JNIEnv<'local>,
    _this: JObject<'local>,
    amount: JString<'local>,
) -> jlong {
    let input: String = match env.get_string(&amount) {
        Ok(s) => s.into(),
        Err(_) => return 0, // JNI failure already left a pending exception.
    };
    match catch_unwind(|| parse_cents_or_panic(&input)) {
        Ok(cents) => cents,
        Err(_) => {
            let _ = env.throw_new(
                "java/lang/RuntimeException",
                "panic in parse_cents, details in logcat tag rusterrors",
            );
            0
        }
    }
}

Three details carry the weight. The JNI calls stay outside the closure: get_string reports failure through Result, so the firewall wraps only pure Rust. throw_new does not transfer control the way Kotlin throw does: it records a pending exception inside ART, your Rust keeps running, and the exception fires when the function returns to the JVM, which is why the Err arm still returns a dummy value and why you should return promptly instead of making more JNI calls (Android Developers, JNI tips). And the closure compiles as is because it only captures a shared reference. Capture something &mut and the compiler demands AssertUnwindSafe, its way of asking whether a panic could leave the value half modified. For a local buffer, assert away; for long lived state behind a Part 7 handle, take the question seriously.

One configuration trap: catch_unwind only catches unwinding panics. Set panic = "abort" in your release profile (Cargo Book, Profiles) and every panic aborts on the spot, firewall or not: you save some binary size and give up the whole safety net. Inside an app process I keep unwinding on and spend the size.

On the Kotlin side the crash is now an ordinary exception, caught in app/src/main/java/dev/davthecoder/rustseries/CheckoutViewModel.kt:

fun previewCents(rawInput: String): Long = try {
    ErrorsBridge.parseCentsGuarded(rawInput)
} catch (e: RuntimeException) {
    Log.e("checkout", "native failure: ${e.message}")
    0L
}

The app survives, and the crash reporter records a handled exception instead of a native crash. The full story sits in logcat under the rusterrors tag.

Mapping Result to Kotlin exceptions by hand

The firewall is for bugs, and a decimal comma is not a bug, it is input. Validating input by panic was the naive parser’s real defect. The honest signature returns Result with an error enum saying exactly what can go wrong. Same file, rust/errors/src/lib.rs:

#[derive(Debug)]
pub enum AmountError {
    Empty,
    BadDigit(char),
    TooManyDecimals,
}

impl fmt::Display for AmountError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AmountError::Empty => write!(f, "amount is empty"),
            AmountError::BadDigit(c) => write!(f, "unexpected character {c:?} in amount"),
            AmountError::TooManyDecimals => write!(f, "more than two decimal places"),
        }
    }
}

pub fn parse_cents(input: &str) -> Result<i64, AmountError> {
    let trimmed = input.trim();
    if trimmed.is_empty() {
        return Err(AmountError::Empty);
    }
    let (units, decimals) = trimmed.split_once('.').unwrap_or((trimmed, ""));
    if decimals.len() > 2 {
        return Err(AmountError::TooManyDecimals);
    }
    let digit = |c: char| c.to_digit(10).map(|d| d as i64).ok_or(AmountError::BadDigit(c));
    let mut cents = 0i64;
    for c in units.chars() {
        cents = cents * 10 + digit(c)?;
    }
    cents *= 100;
    let mut scale = 10;
    for c in decimals.chars() {
        cents += digit(c)? * scale;
        scale /= 10;
    }
    Ok(cents)
}

#[no_mangle]
pub extern "system" fn Java_dev_davthecoder_rustseries_ErrorsBridge_parseCents<'local>(
    mut env: JNIEnv<'local>,
    _this: JObject<'local>,
    amount: JString<'local>,
) -> jlong {
    let input: String = match env.get_string(&amount) {
        Ok(s) => s.into(),
        Err(_) => return 0,
    };
    match parse_cents(&input) {
        Ok(cents) => cents,
        Err(e) => {
            let _ = env.throw_new(
                "dev/davthecoder/rustseries/AmountParseException",
                e.to_string(),
            );
            0
        }
    }
}

The shape should feel familiar from Part 2: the enum is Rust’s sealed class, ? is an early return on the failure case, and Display produces what becomes e.message in Kotlin. The interesting choice is the exception class. AmountParseException, defined next to ErrorsBridge and extending IllegalArgumentException, gives callers something specific to catch while generic handlers upstream still behave sensibly. throw_new takes the class name in JNI slash notation, and on a thread that came from the JVM the app class loader resolves it without ceremony.

The call site now reads like Kotlin handling Kotlin:

fun onPayClicked(rawInput: String) {
    val cents = try {
        ErrorsBridge.parseCents(rawInput)
    } catch (e: AmountParseException) {
        showFieldError(e.message ?: "invalid amount")
        return
    }
    startPayment(cents)
}

One habit worth forming: write Display messages fit for a user’s screen and a bug report, and keep secrets out of them.

What UniFFI does with errors and panics

Everything in the previous section is what UniFFI generates for free, which is why Part 3 recommended it for wide API surfaces. Mark an enum as the error type and export a function returning Result, here in the Part 3 crate at rust/uniffi-demo/src/lib.rs (uniffi = "0.29", thiserror = "2"):

#[derive(Debug, thiserror::Error, uniffi::Error)]
pub enum VaultError {
    #[error("vault is locked")]
    Locked,
    #[error("wrong pin, {attempts_left} attempts left")]
    WrongPin { attempts_left: u32 },
}

#[uniffi::export]
pub fn unlock(pin: String) -> Result<(), VaultError> {
    // A toy check, enough to show the generated exception types.
    if pin == "482910" {
        Ok(())
    } else {
        Err(VaultError::WrongPin { attempts_left: 2 })
    }
}

The generated Kotlin turns VaultError into a VaultException hierarchy, one subclass per variant with the fields as properties (UniFFI documentation):

try {
    unlock(pinInput)
    openVault()
} catch (e: VaultException.WrongPin) {
    showError("Wrong PIN, ${e.attemptsLeft} attempts left")
} catch (e: VaultException.Locked) {
    showError("This vault is locked")
}

UniFFI also builds the firewall for you: the generated scaffolding wraps every exported call, and a panic inside surfaces in Kotlin as an InternalException rather than aborting the process, again assuming unwinding panics. The panic hook still belongs in the crate: logcat is where the message and location land, whichever bindings you use.

The policy: Result for expected errors, panic for bugs

The mechanics only pay off with a rule for which channel each failure uses:

Failure Channel in Rust Arrives in Kotlin as
Bad input, missing file, refused connection Result::Err Typed exception via throw_new or UniFFI
Broken invariant, impossible state, logic bug panic! or assert! Firewall exception plus a logcat entry, then you fix the bug
Memory corruption in unsafe code Nothing catchable Tombstone, read with the hub’s debugging workflow

Never panic for input validation. That was the naive parser’s sin, and it is the most common way Rust crashes reach Android users, because CLI habits travel with ported code. Keep assert! for invariants only a bug can break; when one fires through the firewall, the cost is a single failed call and the logcat line tells you what to fix. Two boundaries deserve extra suspicion. The close() path of a Part 7 handle should never panic, since it runs in finally blocks where a second failure hides the first. And catch_unwind is not a sandbox: it intercepts unwinding, not corruption, so if unsafe code scribbles over memory the tombstone is the only witness.

Frequently Asked Questions

Can Kotlin catch a Rust panic on Android?

Not directly. A panic that escapes a JNI entry point aborts the process before any Kotlin code runs, so there is nothing to catch. Wrap the Rust body in std::panic::catch_unwind and throw a Java exception from the Err arm; then an ordinary Kotlin try block works.

Why do my Rust panic messages not show up in logcat?

Rust writes panic output to stderr, and Android discards stderr by default. Install android_logger and register a hook with std::panic::set_hook when your library loads; after that, every panic message and location lands in logcat under your tag before the process dies.

Does UniFFI handle Rust panics for me?

Mostly. The generated scaffolding wraps each exported call, and a panic surfaces in Kotlin as an InternalException instead of aborting, as long as the crate compiles with unwinding panics. Still model expected failures as Result with an error enum, so callers get typed exceptions instead of internal ones.

Where this leaves you

The errors crate now fails the way Kotlin code fails. Bad input comes back as a typed exception, and a genuine bug reaches the caller as one logged RuntimeException instead of a dead process. What remains uncatchable, mostly memory corruption from unsafe blocks, still ends in a tombstone, and the hub’s debugging section covers reading those.

The next part puts this discipline under real pressure: Part 9, Threading and Async: Tokio, Kotlin Coroutines, and Calling Back Into Kotlin from Rust, moves Rust onto threads the JVM has never heard of, where a panic has no JNI frame to be caught in. The rules from this post are load bearing there.

Share:

Comments

Loading comments…