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:
+379
-313
@@ -3,6 +3,7 @@
|
||||
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
|
||||
//! through JNI calls to Kotlin code.
|
||||
|
||||
use super::jni_guard::jni_guard;
|
||||
use crate::utils::lock::MutexSafe;
|
||||
use log::debug;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
@@ -677,42 +678,47 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
position: jdouble,
|
||||
duration: jdouble,
|
||||
) {
|
||||
// Debug: Log every 10th update to avoid spam
|
||||
static mut UPDATE_COUNTER: u32 = 0;
|
||||
unsafe {
|
||||
UPDATE_COUNTER += 1;
|
||||
if UPDATE_COUNTER % 10 == 0 {
|
||||
log::debug!("[Android] Position update {} / {}", position, duration);
|
||||
}
|
||||
}
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate",
|
||||
|| {
|
||||
// Debug: Log every 10th update to avoid spam
|
||||
static mut UPDATE_COUNTER: u32 = 0;
|
||||
unsafe {
|
||||
UPDATE_COUNTER += 1;
|
||||
if UPDATE_COUNTER % 10 == 0 {
|
||||
log::debug!("[Android] Position update {} / {}", position, duration);
|
||||
}
|
||||
}
|
||||
|
||||
// Update state and get the preserved duration to emit
|
||||
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
|
||||
let mut state = state.lock_safe();
|
||||
state.position = position;
|
||||
if duration > 0.0 {
|
||||
state.duration = Some(duration);
|
||||
}
|
||||
// Use preserved duration from state, or fall back to the received value
|
||||
state.duration.unwrap_or(duration)
|
||||
} else {
|
||||
duration
|
||||
};
|
||||
// Update state and get the preserved duration to emit
|
||||
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
|
||||
let mut state = state.lock_safe();
|
||||
state.position = position;
|
||||
if duration > 0.0 {
|
||||
state.duration = Some(duration);
|
||||
}
|
||||
// Use preserved duration from state, or fall back to the received value
|
||||
state.duration.unwrap_or(duration)
|
||||
} else {
|
||||
duration
|
||||
};
|
||||
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PositionUpdate {
|
||||
position,
|
||||
duration: duration_to_emit,
|
||||
});
|
||||
} else {
|
||||
log::error!("[Android] WARNING: No event emitter for position update!");
|
||||
}
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PositionUpdate {
|
||||
position,
|
||||
duration: duration_to_emit,
|
||||
});
|
||||
} else {
|
||||
log::error!("[Android] WARNING: No event emitter for position update!");
|
||||
}
|
||||
|
||||
// Throttled progress reporting to Jellyfin so playback position syncs and can
|
||||
// be resumed on another device. ExoPlayer only fires position updates while
|
||||
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
|
||||
// progress loop; both share the same EventThrottler (every 30s per item).
|
||||
report_android_progress(position);
|
||||
// Throttled progress reporting to Jellyfin so playback position syncs and can
|
||||
// be resumed on another device. ExoPlayer only fires position updates while
|
||||
// playing, but guard on the stored state anyway. Mirrors the MPV backend's
|
||||
// progress loop; both share the same EventThrottler (every 30s per item).
|
||||
report_android_progress(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());
|
||||
} else {
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(spawn_report());
|
||||
// Not `unwrap()`: this runs on a JNI thread, and `Runtime::new()`
|
||||
// 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,51 +805,56 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
state: JString,
|
||||
media_id: JString,
|
||||
) {
|
||||
let state_str: String = env.get_string(&state).map(|s| s.into()).unwrap_or_default();
|
||||
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 media_id_opt: Option<String> = if media_id.is_null() {
|
||||
None
|
||||
} else {
|
||||
env.get_string(&media_id).map(|s| s.into()).ok()
|
||||
};
|
||||
let media_id_opt: Option<String> = if media_id.is_null() {
|
||||
None
|
||||
} else {
|
||||
env.get_string(&media_id).map(|s| s.into()).ok()
|
||||
};
|
||||
|
||||
// Update shared state
|
||||
if let Some(shared) = SHARED_STATE.get() {
|
||||
let mut shared = shared.lock_safe();
|
||||
if let Some(media) = shared.current_media.clone() {
|
||||
let duration = shared.duration.unwrap_or(0.0);
|
||||
let position = shared.position;
|
||||
match state_str.as_str() {
|
||||
"playing" => {
|
||||
shared.state = PlayerState::Playing {
|
||||
media,
|
||||
position,
|
||||
duration,
|
||||
};
|
||||
shared.is_loaded = true;
|
||||
// Update shared state
|
||||
if let Some(shared) = SHARED_STATE.get() {
|
||||
let mut shared = shared.lock_safe();
|
||||
if let Some(media) = shared.current_media.clone() {
|
||||
let duration = shared.duration.unwrap_or(0.0);
|
||||
let position = shared.position;
|
||||
match state_str.as_str() {
|
||||
"playing" => {
|
||||
shared.state = PlayerState::Playing {
|
||||
media,
|
||||
position,
|
||||
duration,
|
||||
};
|
||||
shared.is_loaded = true;
|
||||
}
|
||||
"paused" => {
|
||||
shared.state = PlayerState::Paused {
|
||||
media,
|
||||
position,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
"idle" => {
|
||||
shared.state = PlayerState::Idle;
|
||||
shared.is_loaded = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"paused" => {
|
||||
shared.state = PlayerState::Paused {
|
||||
media,
|
||||
position,
|
||||
duration,
|
||||
};
|
||||
}
|
||||
"idle" => {
|
||||
shared.state = PlayerState::Idle;
|
||||
shared.is_loaded = false;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: state_str,
|
||||
media_id: media_id_opt,
|
||||
});
|
||||
}
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||
state: state_str,
|
||||
media_id: media_id_opt,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when media has finished loading.
|
||||
@@ -844,15 +864,20 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
_class: JClass,
|
||||
duration: jdouble,
|
||||
) {
|
||||
if let Some(state) = SHARED_STATE.get() {
|
||||
let mut state = state.lock_safe();
|
||||
state.duration = Some(duration);
|
||||
state.is_loaded = true;
|
||||
}
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnMediaLoaded",
|
||||
|| {
|
||||
if let Some(state) = SHARED_STATE.get() {
|
||||
let mut state = state.lock_safe();
|
||||
state.duration = Some(duration);
|
||||
state.is_loaded = true;
|
||||
}
|
||||
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
|
||||
}
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::MediaLoaded { duration });
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when playback reaches the end.
|
||||
@@ -861,49 +886,38 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
_env: JNIEnv,
|
||||
_class: JClass,
|
||||
) {
|
||||
log::info!("[ExoPlayer] Playback ended - processing autoplay decision");
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPlaybackEnded",
|
||||
|| {
|
||||
log::info!("[ExoPlayer] Playback ended - processing autoplay decision");
|
||||
|
||||
// Get player controller and handle autoplay decision
|
||||
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
||||
let controller = controller.clone();
|
||||
// Get player controller and handle autoplay decision
|
||||
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
||||
let controller = controller.clone();
|
||||
|
||||
// Spawn async task to handle autoplay decision
|
||||
// Use tauri::async_runtime::spawn instead of tokio::spawn
|
||||
// JNI callbacks happen on arbitrary threads without a Tokio runtime
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Compute the autoplay decision and release the lock before matching.
|
||||
// Holding the guard across the match would deadlock the AdvanceToNext
|
||||
// arm, which re-locks the controller to call next() — leaving playback
|
||||
// stopped (paused at position 0) instead of advancing.
|
||||
let decision = controller.lock().await.on_playback_ended().await;
|
||||
match decision {
|
||||
Ok(AutoplayDecision::Stop) => {
|
||||
log::debug!("[Autoplay] Decision: Stop playback");
|
||||
// Emit PlaybackEnded event to frontend
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::AdvanceToNext) => {
|
||||
log::debug!("[Autoplay] Decision: Advance to next track");
|
||||
// Advance to next track in queue
|
||||
let ctrl = controller.lock().await;
|
||||
// Spawn async task to handle autoplay decision
|
||||
// Use tauri::async_runtime::spawn instead of tokio::spawn
|
||||
// JNI callbacks happen on arbitrary threads without a Tokio runtime
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Compute the autoplay decision and release the lock before matching.
|
||||
// Holding the guard across the match would deadlock the AdvanceToNext
|
||||
// arm, which re-locks the controller to call next() — leaving playback
|
||||
// stopped (paused at position 0) instead of advancing.
|
||||
let decision = controller.lock().await.on_playback_ended().await;
|
||||
match decision {
|
||||
Ok(AutoplayDecision::Stop) => {
|
||||
log::debug!("[Autoplay] Decision: Stop playback");
|
||||
// Emit PlaybackEnded event to frontend
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::AdvanceToNext) => {
|
||||
log::debug!("[Autoplay] Decision: Advance to next track");
|
||||
// Advance to next track in queue
|
||||
let ctrl = controller.lock().await;
|
||||
|
||||
// Log queue state before advancing
|
||||
let queue_info = {
|
||||
let queue = ctrl.queue.lock_safe();
|
||||
format!(
|
||||
"current_index={:?}, len={}",
|
||||
queue.current_index(),
|
||||
queue.items().len()
|
||||
)
|
||||
};
|
||||
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
|
||||
|
||||
match ctrl.next() {
|
||||
Ok(_) => {
|
||||
log::info!("[Autoplay] Successfully advanced to next track");
|
||||
// Log queue state after advancing
|
||||
// Log queue state before advancing
|
||||
let queue_info = {
|
||||
let queue = ctrl.queue.lock_safe();
|
||||
format!(
|
||||
@@ -912,93 +926,115 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
queue.items().len()
|
||||
)
|
||||
};
|
||||
log::debug!("[Autoplay] Queue state after next(): {}", queue_info);
|
||||
log::debug!("[Autoplay] Queue state before next(): {}", queue_info);
|
||||
|
||||
// Emit queue changed event so frontend updates UI with new current track
|
||||
ctrl.emit_queue_changed();
|
||||
match ctrl.next() {
|
||||
Ok(_) => {
|
||||
log::info!("[Autoplay] Successfully advanced to next track");
|
||||
// Log queue state after advancing
|
||||
let queue_info = {
|
||||
let queue = ctrl.queue.lock_safe();
|
||||
format!(
|
||||
"current_index={:?}, len={}",
|
||||
queue.current_index(),
|
||||
queue.items().len()
|
||||
)
|
||||
};
|
||||
log::debug!(
|
||||
"[Autoplay] Queue state after next(): {}",
|
||||
queue_info
|
||||
);
|
||||
|
||||
// Emit queue changed event so frontend updates UI with new current track
|
||||
ctrl.emit_queue_changed();
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"[Autoplay] Failed to advance to next track: {}",
|
||||
e
|
||||
);
|
||||
// Emit PlaybackEnded event on error
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||
current_episode,
|
||||
next_episode,
|
||||
countdown_seconds,
|
||||
auto_advance,
|
||||
}) => {
|
||||
log::info!(
|
||||
"[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
|
||||
countdown_seconds,
|
||||
auto_advance
|
||||
);
|
||||
|
||||
// Emit popup event to frontend
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
|
||||
current_episode: current_episode.clone(),
|
||||
next_episode: next_episode.clone(),
|
||||
countdown_seconds,
|
||||
auto_advance,
|
||||
});
|
||||
}
|
||||
|
||||
if auto_advance {
|
||||
// Shared with the frontend-invoked command path
|
||||
// (player_on_playback_ended) so the two dispatchers cannot
|
||||
// disagree about how a background audio-only episode
|
||||
// advances — they did, and the command's copy was missing
|
||||
// the case entirely. That copy is the one that actually
|
||||
// decides here: the end reason set at load makes this
|
||||
// callback's own decision Stop, and the frontend echoes the
|
||||
// resulting PlaybackEnded back into the command.
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::ResumeStream { position }) => {
|
||||
// ExoPlayer reported ENDED because the progressive transcode's
|
||||
// connection dropped, not because the episode finished. This
|
||||
// is the arm that matters while backgrounded: it needs no
|
||||
// frontend echo, so the stream re-opens even with the webview
|
||||
// suspended — and playback never parks in STATE_ENDED, where
|
||||
// the next lockscreen/Bluetooth play restarts the item at 0:00.
|
||||
log::info!(
|
||||
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
|
||||
position
|
||||
);
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[Autoplay] Failed to advance to next track: {}", e);
|
||||
log::error!("[Autoplay] Decision failed: {}", e);
|
||||
// Emit PlaybackEnded event on error
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::ShowNextEpisodePopup {
|
||||
current_episode,
|
||||
next_episode,
|
||||
countdown_seconds,
|
||||
auto_advance,
|
||||
}) => {
|
||||
log::info!(
|
||||
"[Autoplay] Decision: Show next episode popup (countdown: {}s, auto: {})",
|
||||
countdown_seconds,
|
||||
auto_advance
|
||||
);
|
||||
|
||||
// Emit popup event to frontend
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::ShowNextEpisodePopup {
|
||||
current_episode: current_episode.clone(),
|
||||
next_episode: next_episode.clone(),
|
||||
countdown_seconds,
|
||||
auto_advance,
|
||||
});
|
||||
}
|
||||
|
||||
if auto_advance {
|
||||
// Shared with the frontend-invoked command path
|
||||
// (player_on_playback_ended) so the two dispatchers cannot
|
||||
// disagree about how a background audio-only episode
|
||||
// advances — they did, and the command's copy was missing
|
||||
// the case entirely. That copy is the one that actually
|
||||
// decides here: the end reason set at load makes this
|
||||
// callback's own decision Stop, and the frontend echoes the
|
||||
// resulting PlaybackEnded back into the command.
|
||||
controller
|
||||
.lock()
|
||||
.await
|
||||
.auto_advance_to_next_episode(next_episode, countdown_seconds)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
Ok(AutoplayDecision::ResumeStream { position }) => {
|
||||
// ExoPlayer reported ENDED because the progressive transcode's
|
||||
// connection dropped, not because the episode finished. This
|
||||
// is the arm that matters while backgrounded: it needs no
|
||||
// frontend echo, so the stream re-opens even with the webview
|
||||
// suspended — and playback never parks in STATE_ENDED, where
|
||||
// the next lockscreen/Bluetooth play restarts the item at 0:00.
|
||||
log::info!(
|
||||
"[Autoplay] Decision: Resume truncated stream at {:.1}s",
|
||||
position
|
||||
);
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||
log::error!("[Autoplay] Failed to resume truncated stream: {}", e);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("[Autoplay] Decision failed: {}", e);
|
||||
// Emit PlaybackEnded event on error
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log::warn!("[Autoplay] PlayerController not initialized - emitting PlaybackEnded");
|
||||
// Fallback: just emit PlaybackEnded event
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log::warn!("[Autoplay] PlayerController not initialized - emitting PlaybackEnded");
|
||||
// Fallback: just emit PlaybackEnded event
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when buffering state changes.
|
||||
@@ -1008,11 +1044,16 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
_class: JClass,
|
||||
percent: jint,
|
||||
) {
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Buffering {
|
||||
percent: percent as u8,
|
||||
});
|
||||
}
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnBuffering",
|
||||
|| {
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Buffering {
|
||||
percent: percent as u8,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when a playback error occurs.
|
||||
@@ -1023,67 +1064,72 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
message: JString,
|
||||
recoverable: jboolean,
|
||||
) {
|
||||
let message_str: String = env
|
||||
.get_string(&message)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let recoverable = recoverable != 0;
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnError",
|
||||
|| {
|
||||
let message_str: String = env
|
||||
.get_string(&message)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
||||
let recoverable = recoverable != 0;
|
||||
|
||||
// A background audio-only handoff is an mp3 the device was already decoding,
|
||||
// so a recoverable failure part-way through is the network. Surfacing it as a
|
||||
// player error stops playback for good (the frontend's handler calls
|
||||
// player_stop); re-opening the stream where it died is the "buffer and
|
||||
// resume" this actually is. Everything else keeps reporting the error.
|
||||
if recoverable {
|
||||
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
||||
let controller = controller.clone();
|
||||
let message_str = message_str.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let resume = controller.lock().await.recoverable_error_resume();
|
||||
let Some((position, delay_secs)) = resume else {
|
||||
// Declined here, so report it as NOT recoverable: the frontend
|
||||
// would otherwise echo it into player_recover_stream and ask
|
||||
// the same question a second time.
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
// A background audio-only handoff is an mp3 the device was already decoding,
|
||||
// so a recoverable failure part-way through is the network. Surfacing it as a
|
||||
// player error stops playback for good (the frontend's handler calls
|
||||
// player_stop); re-opening the stream where it died is the "buffer and
|
||||
// resume" this actually is. Everything else keeps reporting the error.
|
||||
if recoverable {
|
||||
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
||||
let controller = controller.clone();
|
||||
let message_str = message_str.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let resume = controller.lock().await.recoverable_error_resume();
|
||||
let Some((position, delay_secs)) = resume else {
|
||||
// Declined here, so report it as NOT recoverable: the frontend
|
||||
// would otherwise echo it into player_recover_stream and ask
|
||||
// the same question a second time.
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
return;
|
||||
};
|
||||
|
||||
log::warn!(
|
||||
"[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s",
|
||||
message_str,
|
||||
position,
|
||||
delay_secs
|
||||
);
|
||||
// Give a brief outage time to clear before asking the server for
|
||||
// the stream again; retrying instantly just burns the budget.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
|
||||
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||
log::error!("[ExoPlayer] Failed to resume after error: {}", e);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
};
|
||||
|
||||
log::warn!(
|
||||
"[ExoPlayer] Recoverable stream error ({}) — re-opening at {:.1}s in {}s",
|
||||
message_str,
|
||||
position,
|
||||
delay_secs
|
||||
);
|
||||
// Give a brief outage time to clear before asking the server for
|
||||
// the stream again; retrying instantly just burns the budget.
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay_secs)).await;
|
||||
|
||||
let ctrl = controller.lock().await;
|
||||
if let Err(e) = ctrl.resume_stream_at(position).await {
|
||||
log::error!("[ExoPlayer] Failed to resume after error: {}", e);
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable,
|
||||
});
|
||||
}
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::Error {
|
||||
message: message_str,
|
||||
recoverable,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Called when volume changes.
|
||||
@@ -1094,16 +1140,21 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
||||
volume: jfloat,
|
||||
muted: jboolean,
|
||||
) {
|
||||
if let Some(state) = SHARED_STATE.get() {
|
||||
state.lock_safe().volume = volume;
|
||||
}
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnVolumeChanged",
|
||||
|| {
|
||||
if let Some(state) = SHARED_STATE.get() {
|
||||
state.lock_safe().volume = volume;
|
||||
}
|
||||
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::VolumeChanged {
|
||||
volume,
|
||||
muted: muted != 0,
|
||||
});
|
||||
}
|
||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||
emitter.emit(PlayerStatusEvent::VolumeChanged {
|
||||
volume,
|
||||
muted: muted != 0,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// JNI callback for MediaSession commands from JellyTauPlaybackService
|
||||
@@ -1127,14 +1178,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
|
||||
_class: JClass,
|
||||
command: JString,
|
||||
) {
|
||||
let command_str: String = env
|
||||
.get_string(&command)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand",
|
||||
|| {
|
||||
let command_str: String = env
|
||||
.get_string(&command)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(handler) = MEDIA_COMMAND_HANDLER.get() {
|
||||
handler.on_command(&command_str);
|
||||
}
|
||||
if let Some(handler) = MEDIA_COMMAND_HANDLER.get() {
|
||||
handler.on_command(&command_str);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// JNI callback from JellyTauPlaybackService when volume buttons are pressed in remote mode.
|
||||
@@ -1148,14 +1204,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
|
||||
command: JString,
|
||||
volume: jint,
|
||||
) {
|
||||
let command_str: String = env
|
||||
.get_string(&command)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnRemoteVolumeChange",
|
||||
|| {
|
||||
let command_str: String = env
|
||||
.get_string(&command)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(handler) = REMOTE_VOLUME_HANDLER.get() {
|
||||
handler.on_remote_volume_change(&command_str, volume as i32);
|
||||
}
|
||||
if let Some(handler) = REMOTE_VOLUME_HANDLER.get() {
|
||||
handler.on_remote_volume_change(&command_str, volume as i32);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// JNI callback from Kotlin when codec detection completes.
|
||||
@@ -1170,40 +1231,45 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Co
|
||||
audio_codecs: JString,
|
||||
max_audio_channels: jint,
|
||||
) {
|
||||
let video_str: String = env
|
||||
.get_string(&video_codecs)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
jni_guard(
|
||||
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Companion_nativeOnCodecsDetected",
|
||||
|| {
|
||||
let video_str: String = env
|
||||
.get_string(&video_codecs)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
|
||||
let audio_str: String = env
|
||||
.get_string(&audio_codecs)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
let audio_str: String = env
|
||||
.get_string(&audio_codecs)
|
||||
.map(|s| s.into())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
|
||||
let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);
|
||||
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
|
||||
let channels = u32::try_from(max_audio_channels).ok().filter(|c| *c > 0);
|
||||
|
||||
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);
|
||||
let codecs = DetectedCodecs::from_jni_strings(&video_str, &audio_str, channels);
|
||||
|
||||
log::info!(
|
||||
"[CodecDetection] Detected {} video codecs: {}",
|
||||
codecs.video_codecs.len(),
|
||||
codecs.video_codecs_string()
|
||||
log::info!(
|
||||
"[CodecDetection] Detected {} video codecs: {}",
|
||||
codecs.video_codecs.len(),
|
||||
codecs.video_codecs_string()
|
||||
);
|
||||
log::info!(
|
||||
"[CodecDetection] Detected {} audio codecs: {}",
|
||||
codecs.audio_codecs.len(),
|
||||
codecs.audio_codecs_string()
|
||||
);
|
||||
log::info!(
|
||||
"[CodecDetection] Audio route max channels: {:?}",
|
||||
codecs.max_audio_channels
|
||||
);
|
||||
|
||||
// Store in global state
|
||||
if DETECTED_CODECS.set(codecs).is_err() {
|
||||
log::error!("[CodecDetection] Failed to store codecs - already initialized");
|
||||
}
|
||||
},
|
||||
);
|
||||
log::info!(
|
||||
"[CodecDetection] Detected {} audio codecs: {}",
|
||||
codecs.audio_codecs.len(),
|
||||
codecs.audio_codecs_string()
|
||||
);
|
||||
log::info!(
|
||||
"[CodecDetection] Audio route max channels: {:?}",
|
||||
codecs.max_audio_channels
|
||||
);
|
||||
|
||||
// Store in global state
|
||||
if DETECTED_CODECS.set(codecs).is_err() {
|
||||
log::error!("[CodecDetection] Failed to store codecs - already initialized");
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the JellyTauPlaybackService if not already running.
|
||||
|
||||
@@ -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 ")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,10 @@ pub mod track_switch;
|
||||
#[cfg(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
|
||||
#[cfg(target_os = "android")]
|
||||
pub mod android;
|
||||
|
||||
Reference in New Issue
Block a user