Skip to content
Rust & NativeRust y nativo

Error Handling and Panics: Keeping Rust Crashes Out of Your Android AppGestión de errores y panics: que los crashes de Rust no se cuelen en tu app de Android

ByPor David Cruz Anaya, Senior Mobile & Applied AI Engineer (Android, Kotlin Multiplatform, product AI)Ingeniero sénior de móvil e IA aplicada (Android, Kotlin Multiplatform, IA de producto)

Updated 11 September 2026Actualizado el 11 de septiembre de 20268 min read8 min de lectura
AndroidRustNDKJNIError HandlingPanics
Error Handling and Panics: Keeping Rust Crashes Out of Your Android App
Share:Compartir:

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.

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).

The panic message is missing. 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.

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
        }
    }
}

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.

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. 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)
}

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. The close() path of a Part 7 handle should never panic, since it runs in finally blocks where a second failure hides the first. catch_unwind intercepts unwinding. If unsafe code scribbles over memory the tombstone is the only witness, 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.

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.

Nada en el lado de Kotlin puede capturar un panic de Rust. Cuando un panic llega a la frontera JNI, el proceso desaparece: desde Rust 1.81, un panic que intenta hacer unwind fuera de una función extern "system" aborta, en lugar de derivar hacia comportamiento indefinido (Rust Blog, Announcing Rust 1.81.0, 2024). En Android ese abort es un crash nativo: el usuario ve cómo la app se cierra a mitad de un toque, y tú recibes un tombstone con SIGABRT. Ningún bloque try lo ve nunca, porque no hay excepción, solo un proceso que se está muriendo.

Esta parte construye tres capas de defensa: un hook de panic que escribe el mensaje en logcat antes de que muera el proceso, un cortafuegos catch_unwind que convierte los bugs escapados en excepciones normales de Kotlin, y Result mapeado a excepciones tipadas para cada fallo que puedes prever. Esto asume la cadena de herramientas de la parte 1, y las reglas de ownership de la parte 7 se aplican a todo lo que sigue.

Puntos clave

  • Un panic que escapa de un punto de entrada JNI aborta toda la app. Desde Rust 1.81 esto está garantizado para las funciones extern "system", y ya no es comportamiento indefinido (Rust Blog, 2024).
  • Rust imprime los mensajes de panic en stderr, y Android tira stderr a la basura. Un hook de panic más android_logger mete el mensaje en logcat antes del abort.
  • std::panic::catch_unwind en la frontera convierte un panic en una excepción de Java lanzada. Solo funciona mientras panic = "unwind" está en vigor, así que no pongas nunca panic = "abort" en un perfil que dependa del cortafuegos.
  • Los fallos esperados no deberían hacer panic nunca. Devuelve Result, traduce Err a una excepción tipada de Kotlin con throw_new, o deja que UniFFI genere el mapeo como en la parte 3.

Qué le hace a tu app de Android un panic de Rust bajo JNI

Rust no tiene excepciones; tiene dos canales de error. Result<T, E> es un valor, la misma idea que una clase sellada de Kotlin con un caso de éxito y uno de fallo. Un panic es para bugs, las situaciones que check() pillaría en Kotlin, y hace unwind de la pila marco a marco. El problema empieza cuando el unwind llega a un marco que no es Rust: la maquinaria JNI dentro de ART es C++, y hacer unwind a través de marcos ajenos es comportamiento indefinido (The Rustonomicon, FFI). Rust 1.81 cerró esa trampilla abortando el proceso en el momento en que un panic intenta escapar de una función extern "system". La ABI C-unwind existe para la interoperabilidad con C++, pero ART no puede hacer nada útil con un unwind de Rust, así que bajo JNI no cambia nada.

Vamos a romperlo a propósito. El crate de la demo vive en rust/errors; su rust/errors/Cargo.toml declara el cdylib de siempre más el 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"

Aquí va el principio de rust/errors/src/lib.rs, todos los imports que el archivo va a necesitar, más un parser portado de una herramienta de línea de comandos. En el escritorio, hacer panic ante una entrada mala solo imprime y sale, así que mucho Rust real está escrito así:

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)
}

El puente de Kotlin en app/src/main/java/dev/davthecoder/rustseries/ErrorsBridge.kt declara todo lo que construye este artículo; nativeInit se explica en la sección siguiente:

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
}

Llama a ErrorsBridge.parseCentsNaive("12.50") y todos los tests pasan. Luego un usuario en Berlín escribe 12,50, con coma decimal, el closure digit hace panic y el proceso aborta. El tombstone, recortado:

*** *** *** *** *** *** *** *** *** *** *** *** *** *** *** ***
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)

Cómo leerlo: la señal 6 significa que el proceso pidió a bionic que se abortara a sí mismo. El nombre de hilo de 15 caracteres es la cola del nombre de tu paquete. Entre abort y tu propia función están las internas de panic de Rust, luego el punto de entrada JNI y después el trampolín de ART. Convertir direcciones en bruto en estos nombres es el flujo de ndk-stack de la sección de depuración del hub (Android Developers, ndk-stack).

Falta el mensaje del panic. Fue a stderr, y Android descarta stderr. La pila dice dónde, pero el porqué nunca salió del proceso.

Un hook de panic que llega a logcat antes de que muera el proceso

La corrección es una función, llamada una vez cuando se carga la biblioteca, en 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");
}

El crate android_logger encamina las macros estándar de log hacia logcat, e init_once hace que las llamadas repetidas sean inofensivas. El hook encadena el anterior en lugar de sustituirlo, así que cargo test en tu ordenador portátil sigue imprimiendo los panics en el terminal. El bloque init de ErrorsBridge llama a nativeInit justo después de System.loadLibrary, el momento más temprano en el que puede ejecutarse.

Con el hook instalado, el mismo crash de la coma decimal deja ahora esto en adb logcat -s rusterrors justo antes del abort:

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

Archivo, línea y el mensaje real: el tombstone decía dónde murió la app, y esta línea dice por qué. Un hook de panic se ejecuta en el momento del panic, antes de cualquier unwind, así que también se dispara para los panics que captura la sección siguiente. Instálalo en cada crate que publiques.

Un cortafuegos catch_unwind en la frontera JNI

Registrar el panic es diagnóstico; contenerlo es std::panic::catch_unwind, que ejecuta un closure y devuelve Err con el payload del panic en lugar de seguir el unwind. En la frontera JNI se convierte en un cortafuegos: el panic sigue significando un bug, pero el bug cuesta una llamada fallida en lugar del proceso. La versión protegida, también en 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
        }
    }
}

Las llamadas JNI se quedan fuera del closure: get_string informa del fallo mediante Result, así que el cortafuegos envuelve solo Rust puro. throw_new no transfiere el control como lo hace throw en Kotlin: registra una excepción pendiente dentro de ART, tu Rust sigue ejecutándose y la excepción se dispara cuando la función vuelve a la JVM, y por eso el brazo Err sigue devolviendo un valor de relleno y por eso deberías volver enseguida en lugar de hacer más llamadas JNI (Android Developers, JNI tips). Y el closure compila tal cual porque solo captura una referencia compartida. Captura algo &mut y el compilador exige AssertUnwindSafe, su forma de preguntar si un panic podría dejar el valor a medias. Para un búfer local, afirma y sigue; para un estado de larga vida detrás de un handle de la parte 7, tómate la pregunta en serio.

catch_unwind solo captura panics que hacen unwind. Pon panic = "abort" en tu perfil de release (Cargo Book, Profiles) y cada panic aborta en el sitio, haya cortafuegos o no: ahorras algo de tamaño de binario y renuncias a toda la red de seguridad. Dentro del proceso de una app mantengo el unwinding y asumo el tamaño.

En el lado de Kotlin el crash es ahora una excepción normal, capturada en 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
}

La app sobrevive, y el informador de crashes registra una excepción gestionada en lugar de un crash nativo. La historia completa está en logcat bajo la etiqueta rusterrors.

Mapear Result a excepciones de Kotlin a mano

El cortafuegos es para bugs, y una coma decimal no es un bug: es entrada. Validar la entrada mediante panic era el defecto real del parser ingenuo. La firma honesta devuelve Result con un enum de error que dice exactamente qué puede ir mal. El mismo archivo, 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
        }
    }
}

La forma debería resultarte familiar desde la parte 2: el enum es la clase sellada de Rust, ? es un retorno anticipado en el caso de fallo, y Display produce lo que se convierte en e.message en Kotlin. AmountParseException, definida junto a ErrorsBridge y que extiende IllegalArgumentException, da a quien llama algo concreto que capturar, mientras los manejadores genéricos de más arriba siguen comportándose con sentido. throw_new toma el nombre de la clase en notación de barras de JNI, y en un hilo que vino de la JVM el class loader de la app lo resuelve sin ceremonia.

El punto de llamada se lee ahora como Kotlin tratando con Kotlin:

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

Escribe mensajes de Display aptos para la pantalla de un usuario y para un informe de bug, y deja los secretos fuera.

Qué hace UniFFI con los errores y los panics

Todo lo de la sección anterior es lo que UniFFI genera gratis, y por eso la parte 3 lo recomendaba para superficies de API amplias. Marca un enum como el tipo de error y exporta una función que devuelve Result, aquí en el crate de la parte 3, en 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 })
    }
}

El Kotlin generado convierte VaultError en una jerarquía VaultException, una subclase por variante con los campos como propiedades (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 también te construye el cortafuegos: el andamiaje generado envuelve cada llamada exportada, y un panic dentro aparece en Kotlin como una InternalException en lugar de abortar el proceso, de nuevo asumiendo panics con unwind. El hook de panic sigue perteneciendo al crate: logcat es donde aterrizan el mensaje y la ubicación, uses los bindings que uses.

La política: Result para errores esperados, panic para bugs

La mecánica solo compensa con una regla sobre qué canal usa cada fallo:

Fallo Canal en Rust Llega a Kotlin como
Entrada incorrecta, archivo ausente, conexión rechazada Result::Err Excepción tipada mediante throw_new o UniFFI
Invariante rota, estado imposible, bug de lógica panic! o assert! Excepción del cortafuegos más una entrada en logcat; luego corriges el bug
Corrupción de memoria en código unsafe Nada que se pueda capturar Tombstone, leído con el flujo de depuración del hub

No hagas panic nunca para validar entrada. Ese fue el pecado del parser ingenuo, y es la forma más habitual de que los crashes de Rust lleguen a los usuarios de Android, porque los hábitos de la línea de comandos viajan con el código portado. Reserva assert! para invariantes que solo un bug puede romper; cuando una se dispara a través del cortafuegos, el coste es una sola llamada fallida y la línea de logcat te dice qué corregir. La ruta close() de un handle de la parte 7 no debería hacer panic nunca, porque se ejecuta en bloques finally donde un segundo fallo esconde el primero. catch_unwind intercepta el unwind. Si el código unsafe garabatea sobre la memoria, el tombstone es el único testigo, y la sección de depuración del hub explica cómo leerlos.

La parte siguiente pone esta disciplina bajo presión de verdad: la parte 9, Hilos y asincronía: Tokio, corrutinas de Kotlin y devolver la llamada a Kotlin desde Rust, lleva Rust a hilos de los que la JVM nunca ha oído hablar, donde un panic no tiene un marco JNI en el que se le pueda capturar. Las reglas de este artículo son las que sostienen esa parte.

Share:Compartir:

CommentsComentarios

Loading comments…Cargando comentarios…