fix(android): contain panics at the JNI boundary instead of aborting the process

Ten `extern "system"` callbacks are entered by the JVM on arbitrary
threads. A panic unwinding out of one crosses the FFI boundary, which
Rust answers by aborting: the app vanishes with no Java exception, no
attributable stack trace, and no crash report the user can send. For
callbacks that fire four times a second during playback that is the worst
available failure mode.

It was reachable. `nativeOnPositionUpdate` built a fallback Tokio runtime
with `Runtime::new().unwrap()` on threads that have none, and
`Runtime::new()` fails under exactly the fd exhaustion and thread-spawn
refusal Android subjects a media app to. That now logs and drops the
report — losing one progress report is recoverable, losing the app is not.

Every callback body is wrapped in `jni_guard`, which catches the unwind
and logs it. It is a backstop, not a licence to panic: a contained panic
still leaves whatever it interrupted half-done.

The guard lives in `player::jni_guard` rather than `player::android`
because that module is `cfg(target_os = "android")` and so never compiles
on the host — which is why its 1575 lines had no tests at all. A tripwire
test asserts every entry point wraps its body, so an eleventh callback
cannot reintroduce the defect; it reads the source, since exercising the
real boundary needs a JVM.

Verified with `cargo check --target aarch64-linux-android`.
This commit is contained in:
2026-09-07 22:24:45 +02:00
parent d4f80a4afa
commit f9e1a8e69a
3 changed files with 505 additions and 313 deletions
+70 -4
View File
@@ -3,6 +3,7 @@
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer //! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
//! through JNI calls to Kotlin code. //! through JNI calls to Kotlin code.
use super::jni_guard::jni_guard;
use crate::utils::lock::MutexSafe; use crate::utils::lock::MutexSafe;
use log::debug; use log::debug;
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
@@ -677,6 +678,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
position: jdouble, position: jdouble,
duration: jdouble, duration: jdouble,
) { ) {
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate",
|| {
// Debug: Log every 10th update to avoid spam // Debug: Log every 10th update to avoid spam
static mut UPDATE_COUNTER: u32 = 0; static mut UPDATE_COUNTER: u32 = 0;
unsafe { unsafe {
@@ -713,6 +717,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// playing, but guard on the stored state anyway. Mirrors the MPV backend's // playing, but guard on the stored state anyway. Mirrors the MPV backend's
// progress loop; both share the same EventThrottler (every 30s per item). // progress loop; both share the same EventThrottler (every 30s per item).
report_android_progress(position); report_android_progress(position);
},
);
} }
/// Report throttled playback progress to Jellyfin from the Android position /// Report throttled playback progress to Jellyfin from the Android position
@@ -774,8 +780,17 @@ fn report_android_progress(position: f64) {
handle.spawn(spawn_report()); handle.spawn(spawn_report());
} else { } else {
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap(); // Not `unwrap()`: this runs on a JNI thread, and `Runtime::new()`
rt.block_on(spawn_report()); // fails under the fd exhaustion and thread-spawn refusal Android
// subjects a media app to. The panic used to unwind out of the
// `extern "system"` caller and abort the process — losing one
// progress report is recoverable, losing the app is not.
match tokio::runtime::Runtime::new() {
Ok(rt) => rt.block_on(spawn_report()),
Err(e) => log::error!(
"[Android] No runtime available to report progress; dropping it: {e}"
),
}
}); });
} }
@@ -790,6 +805,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
state: JString, state: JString,
media_id: JString, media_id: JString,
) { ) {
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnStateChanged",
|| {
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default(); let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
let media_id_opt: Option<String> = if media_id.is_null() { let media_id_opt: Option<String> = if media_id.is_null() {
@@ -835,6 +853,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
media_id: media_id_opt, media_id: media_id_opt,
}); });
} }
},
);
} }
/// Called when media has finished loading. /// Called when media has finished loading.
@@ -844,6 +864,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
_class: JClass, _class: JClass,
duration: jdouble, duration: jdouble,
) { ) {
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnMediaLoaded",
|| {
if let Some(state) = SHARED_STATE.get() { if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock_safe(); let mut state = state.lock_safe();
state.duration = Some(duration); state.duration = Some(duration);
@@ -853,6 +876,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
if let Some(emitter) = EVENT_EMITTER.get() { if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::MediaLoaded { duration }); emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
} }
},
);
} }
/// Called when playback reaches the end. /// Called when playback reaches the end.
@@ -861,6 +886,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
_env: JNIEnv, _env: JNIEnv,
_class: JClass, _class: JClass,
) { ) {
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPlaybackEnded",
|| {
log::info!("[ExoPlayer] Playback ended - processing autoplay decision"); log::info!("[ExoPlayer] Playback ended - processing autoplay decision");
// Get player controller and handle autoplay decision // Get player controller and handle autoplay decision
@@ -912,13 +940,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
queue.items().len() queue.items().len()
) )
}; };
log::debug!("[Autoplay] Queue state after next(): {}", queue_info); log::debug!(
"[Autoplay] Queue state after next(): {}",
queue_info
);
// Emit queue changed event so frontend updates UI with new current track // Emit queue changed event so frontend updates UI with new current track
ctrl.emit_queue_changed(); ctrl.emit_queue_changed();
} }
Err(e) => { Err(e) => {
log::error!("[Autoplay] Failed to advance to next track: {}", e); log::error!(
"[Autoplay] Failed to advance to next track: {}",
e
);
// Emit PlaybackEnded event on error // Emit PlaybackEnded event on error
if let Some(emitter) = EVENT_EMITTER.get() { if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::PlaybackEnded); emitter.emit(PlayerStatusEvent::PlaybackEnded);
@@ -999,6 +1033,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
emitter.emit(PlayerStatusEvent::PlaybackEnded); emitter.emit(PlayerStatusEvent::PlaybackEnded);
} }
} }
},
);
} }
/// Called when buffering state changes. /// Called when buffering state changes.
@@ -1008,11 +1044,16 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
_class: JClass, _class: JClass,
percent: jint, percent: jint,
) { ) {
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnBuffering",
|| {
if let Some(emitter) = EVENT_EMITTER.get() { if let Some(emitter) = EVENT_EMITTER.get() {
emitter.emit(PlayerStatusEvent::Buffering { emitter.emit(PlayerStatusEvent::Buffering {
percent: percent as u8, percent: percent as u8,
}); });
} }
},
);
} }
/// Called when a playback error occurs. /// Called when a playback error occurs.
@@ -1023,6 +1064,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
message: JString, message: JString,
recoverable: jboolean, recoverable: jboolean,
) { ) {
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnError",
|| {
let message_str: String = env let message_str: String = env
.get_string(&message) .get_string(&message)
.map(|s| s.into()) .map(|s| s.into())
@@ -1084,6 +1128,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
recoverable, recoverable,
}); });
} }
},
);
} }
/// Called when volume changes. /// Called when volume changes.
@@ -1094,6 +1140,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
volume: jfloat, volume: jfloat,
muted: jboolean, muted: jboolean,
) { ) {
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnVolumeChanged",
|| {
if let Some(state) = SHARED_STATE.get() { if let Some(state) = SHARED_STATE.get() {
state.lock_safe().volume = volume; state.lock_safe().volume = volume;
} }
@@ -1104,6 +1153,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
muted: muted != 0, muted: muted != 0,
}); });
} }
},
);
} }
// JNI callback for MediaSession commands from JellyTauPlaybackService // JNI callback for MediaSession commands from JellyTauPlaybackService
@@ -1127,6 +1178,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
_class: JClass, _class: JClass,
command: JString, command: JString,
) { ) {
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand",
|| {
let command_str: String = env let command_str: String = env
.get_string(&command) .get_string(&command)
.map(|s| s.into()) .map(|s| s.into())
@@ -1135,6 +1189,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
if let Some(handler) = MEDIA_COMMAND_HANDLER.get() { if let Some(handler) = MEDIA_COMMAND_HANDLER.get() {
handler.on_command(&command_str); handler.on_command(&command_str);
} }
},
);
} }
/// JNI callback from JellyTauPlaybackService when volume buttons are pressed in remote mode. /// JNI callback from JellyTauPlaybackService when volume buttons are pressed in remote mode.
@@ -1148,6 +1204,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
command: JString, command: JString,
volume: jint, volume: jint,
) { ) {
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnRemoteVolumeChange",
|| {
let command_str: String = env let command_str: String = env
.get_string(&command) .get_string(&command)
.map(|s| s.into()) .map(|s| s.into())
@@ -1156,6 +1215,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
if let Some(handler) = REMOTE_VOLUME_HANDLER.get() { if let Some(handler) = REMOTE_VOLUME_HANDLER.get() {
handler.on_remote_volume_change(&command_str, volume as i32); handler.on_remote_volume_change(&command_str, volume as i32);
} }
},
);
} }
/// JNI callback from Kotlin when codec detection completes. /// JNI callback from Kotlin when codec detection completes.
@@ -1170,6 +1231,9 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
audio_codecs: JString, audio_codecs: JString,
max_audio_channels: jint, max_audio_channels: jint,
) { ) {
jni_guard(
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Companion_nativeOnCodecsDetected",
|| {
let video_str: String = env let video_str: String = env
.get_string(&video_codecs) .get_string(&video_codecs)
.map(|s| s.into()) .map(|s| s.into())
@@ -1204,6 +1268,8 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
if DETECTED_CODECS.set(codecs).is_err() { if DETECTED_CODECS.set(codecs).is_err() {
log::error!("[CodecDetection] Failed to store codecs - already initialized"); log::error!("[CodecDetection] Failed to store codecs - already initialized");
} }
},
);
} }
/// Start the JellyTauPlaybackService if not already running. /// Start the JellyTauPlaybackService if not already running.
+122
View File
@@ -0,0 +1,122 @@
//! Panic containment for the Android JNI boundary.
//!
//! Compiled on every platform, unlike `player::android` itself, so the guard and
//! the tripwire that enforces its use are unit-tested on the host — the same
//! reason `RESUME_BACKOFF_STEP_SECS` lives outside the `cfg(android)` block.
/// Run the body of a JNI callback with any panic contained.
///
/// Every `extern "system"` function in this file is called by the JVM on an
/// arbitrary thread. A panic that unwinds out of one crosses the FFI boundary,
/// which Rust answers by **aborting the process** — the app vanishes with no
/// Java exception, no stack trace attributable to it, and no crash report the
/// user can send. That is the worst possible failure mode for the callbacks
/// that fire four times a second during playback.
///
/// The panics are real, not theoretical: this file builds a fallback Tokio
/// runtime on threads that have none, and `Runtime::new()` fails under the fd
/// exhaustion and thread-spawn refusal an Android device puts a media app
/// through. Losing one position report is recoverable; losing the process is
/// not.
///
/// A contained panic still leaves whatever it interrupted half-done, so this is
/// a backstop, not a licence to panic. `utils::lock` already keeps a poisoned
/// mutex from cascading; this keeps the FFI boundary from turning any remaining
/// panic into a process kill.
///
/// TRACES: UR-005 | DR-052
///
/// Only *called* from `player::android`, which is `cfg(target_os = "android")`,
/// so it is dead code on every other target — the same reason
/// `RESUME_BACKOFF_STEP_SECS` carries this attribute. It is still compiled and
/// tested here on purpose.
#[cfg_attr(not(target_os = "android"), allow(dead_code))]
pub(crate) fn jni_guard<F: FnOnce()>(name: &str, body: F) {
// AssertUnwindSafe: the shared state behind these callbacks is already
// reached through poison-tolerant locks, so a panic cannot hand out a
// guard observing a torn value.
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)).is_err() {
// The panic hook has already logged the payload and location.
log::error!("[JNI] Panic in {name} was contained; the callback was dropped");
}
}
// TRACES: UR-005 | DR-052 | UT-052
#[cfg(test)]
mod jni_guard_tests {
use super::*;
/// The guard must swallow a panic rather than let it reach the JVM.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn a_panicking_callback_body_does_not_escape_the_guard() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(|_| {}));
jni_guard("test_callback", || panic!("ExoPlayer callback blew up"));
std::panic::set_hook(hook);
// Reaching here at all is the assertion: without the guard the panic
// would unwind out of the `extern "system"` fn and abort the process.
}
/// The guard must not disturb a callback that behaves.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn a_normal_callback_body_still_runs() {
let mut ran = false;
jni_guard("test_callback", || ran = true);
assert!(ran);
}
/// **Tripwire.** Every JNI entry point must wrap its body in `jni_guard`.
///
/// A panic crossing the `extern "system"` boundary aborts the process, so a
/// twelfth callback added without the guard reintroduces the whole defect.
/// Checked against the source because the real boundary needs a JVM to
/// exercise — the same tripwire idiom as `check:boundary`.
///
/// TRACES: UR-005 | DR-052 | UT-052
#[test]
fn every_jni_entry_point_wraps_its_body_in_the_guard() {
let src = include_str!("android/mod.rs");
let mut unguarded = Vec::new();
let mut lines = src.lines().enumerate().peekable();
while let Some((_, line)) = lines.next() {
if !line.starts_with("pub extern \"system\" fn ") {
continue;
}
let name = line
.trim_start_matches("pub extern \"system\" fn ")
.trim_end_matches('(')
.to_string();
// Walk to the end of the parameter list, then look at the first
// statement of the body.
let mut body_start = None;
for (n, l) in lines.by_ref() {
if l.trim_end().ends_with(") {") || l.trim() == ") {" {
body_start = Some(n);
break;
}
}
assert!(body_start.is_some(), "could not find the body of {name}");
match lines.peek() {
Some((_, first)) if first.trim_start().starts_with("jni_guard(") => {}
other => unguarded.push(format!(
"{name} (body starts with {:?})",
other.map(|(_, l)| l.trim()).unwrap_or("<eof>")
)),
}
}
assert!(
unguarded.is_empty(),
"JNI entry points whose body is not wrapped in jni_guard — a panic in \
one of these aborts the process:\n {}",
unguarded.join("\n ")
);
}
}
+4
View File
@@ -28,6 +28,10 @@ pub mod track_switch;
#[cfg(test)] #[cfg(test)]
mod mpv_backend_test; mod mpv_backend_test;
// Panic containment for the JNI boundary. Not gated on the target: the guard
// and its tripwire test are exercised on the host, where `android` never builds.
pub mod jni_guard;
// Platform-specific backends // Platform-specific backends
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub mod android; pub mod android;