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
|
//! 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,42 +678,47 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
position: jdouble,
|
position: jdouble,
|
||||||
duration: jdouble,
|
duration: jdouble,
|
||||||
) {
|
) {
|
||||||
// Debug: Log every 10th update to avoid spam
|
jni_guard(
|
||||||
static mut UPDATE_COUNTER: u32 = 0;
|
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnPositionUpdate",
|
||||||
unsafe {
|
|| {
|
||||||
UPDATE_COUNTER += 1;
|
// Debug: Log every 10th update to avoid spam
|
||||||
if UPDATE_COUNTER % 10 == 0 {
|
static mut UPDATE_COUNTER: u32 = 0;
|
||||||
log::debug!("[Android] Position update {} / {}", position, duration);
|
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
|
// Update state and get the preserved duration to emit
|
||||||
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
|
let duration_to_emit = if let Some(state) = SHARED_STATE.get() {
|
||||||
let mut state = state.lock_safe();
|
let mut state = state.lock_safe();
|
||||||
state.position = position;
|
state.position = position;
|
||||||
if duration > 0.0 {
|
if duration > 0.0 {
|
||||||
state.duration = Some(duration);
|
state.duration = Some(duration);
|
||||||
}
|
}
|
||||||
// Use preserved duration from state, or fall back to the received value
|
// Use preserved duration from state, or fall back to the received value
|
||||||
state.duration.unwrap_or(duration)
|
state.duration.unwrap_or(duration)
|
||||||
} else {
|
} else {
|
||||||
duration
|
duration
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
emitter.emit(PlayerStatusEvent::PositionUpdate {
|
emitter.emit(PlayerStatusEvent::PositionUpdate {
|
||||||
position,
|
position,
|
||||||
duration: duration_to_emit,
|
duration: duration_to_emit,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
log::error!("[Android] WARNING: No event emitter for position update!");
|
log::error!("[Android] WARNING: No event emitter for position update!");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Throttled progress reporting to Jellyfin so playback position syncs and can
|
// Throttled progress reporting to Jellyfin so playback position syncs and can
|
||||||
// be resumed on another device. ExoPlayer only fires position updates while
|
// be resumed on another device. ExoPlayer only fires position updates while
|
||||||
// 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,51 +805,56 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
state: JString,
|
state: JString,
|
||||||
media_id: 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() {
|
let media_id_opt: Option<String> = if media_id.is_null() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
env.get_string(&media_id).map(|s| s.into()).ok()
|
env.get_string(&media_id).map(|s| s.into()).ok()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update shared state
|
// Update shared state
|
||||||
if let Some(shared) = SHARED_STATE.get() {
|
if let Some(shared) = SHARED_STATE.get() {
|
||||||
let mut shared = shared.lock_safe();
|
let mut shared = shared.lock_safe();
|
||||||
if let Some(media) = shared.current_media.clone() {
|
if let Some(media) = shared.current_media.clone() {
|
||||||
let duration = shared.duration.unwrap_or(0.0);
|
let duration = shared.duration.unwrap_or(0.0);
|
||||||
let position = shared.position;
|
let position = shared.position;
|
||||||
match state_str.as_str() {
|
match state_str.as_str() {
|
||||||
"playing" => {
|
"playing" => {
|
||||||
shared.state = PlayerState::Playing {
|
shared.state = PlayerState::Playing {
|
||||||
media,
|
media,
|
||||||
position,
|
position,
|
||||||
duration,
|
duration,
|
||||||
};
|
};
|
||||||
shared.is_loaded = true;
|
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() {
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
emitter.emit(PlayerStatusEvent::StateChanged {
|
emitter.emit(PlayerStatusEvent::StateChanged {
|
||||||
state: state_str,
|
state: state_str,
|
||||||
media_id: media_id_opt,
|
media_id: media_id_opt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when media has finished loading.
|
/// Called when media has finished loading.
|
||||||
@@ -844,15 +864,20 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
_class: JClass,
|
_class: JClass,
|
||||||
duration: jdouble,
|
duration: jdouble,
|
||||||
) {
|
) {
|
||||||
if let Some(state) = SHARED_STATE.get() {
|
jni_guard(
|
||||||
let mut state = state.lock_safe();
|
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnMediaLoaded",
|
||||||
state.duration = Some(duration);
|
|| {
|
||||||
state.is_loaded = true;
|
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() {
|
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,49 +886,38 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
_env: JNIEnv,
|
_env: JNIEnv,
|
||||||
_class: JClass,
|
_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
|
// Get player controller and handle autoplay decision
|
||||||
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
||||||
let controller = controller.clone();
|
let controller = controller.clone();
|
||||||
|
|
||||||
// Spawn async task to handle autoplay decision
|
// Spawn async task to handle autoplay decision
|
||||||
// Use tauri::async_runtime::spawn instead of tokio::spawn
|
// Use tauri::async_runtime::spawn instead of tokio::spawn
|
||||||
// JNI callbacks happen on arbitrary threads without a Tokio runtime
|
// JNI callbacks happen on arbitrary threads without a Tokio runtime
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
// Compute the autoplay decision and release the lock before matching.
|
// Compute the autoplay decision and release the lock before matching.
|
||||||
// Holding the guard across the match would deadlock the AdvanceToNext
|
// Holding the guard across the match would deadlock the AdvanceToNext
|
||||||
// arm, which re-locks the controller to call next() — leaving playback
|
// arm, which re-locks the controller to call next() — leaving playback
|
||||||
// stopped (paused at position 0) instead of advancing.
|
// stopped (paused at position 0) instead of advancing.
|
||||||
let decision = controller.lock().await.on_playback_ended().await;
|
let decision = controller.lock().await.on_playback_ended().await;
|
||||||
match decision {
|
match decision {
|
||||||
Ok(AutoplayDecision::Stop) => {
|
Ok(AutoplayDecision::Stop) => {
|
||||||
log::debug!("[Autoplay] Decision: Stop playback");
|
log::debug!("[Autoplay] Decision: Stop playback");
|
||||||
// Emit PlaybackEnded event to frontend
|
// Emit PlaybackEnded event to frontend
|
||||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(AutoplayDecision::AdvanceToNext) => {
|
Ok(AutoplayDecision::AdvanceToNext) => {
|
||||||
log::debug!("[Autoplay] Decision: Advance to next track");
|
log::debug!("[Autoplay] Decision: Advance to next track");
|
||||||
// Advance to next track in queue
|
// Advance to next track in queue
|
||||||
let ctrl = controller.lock().await;
|
let ctrl = controller.lock().await;
|
||||||
|
|
||||||
// Log queue state before advancing
|
// 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
|
|
||||||
let queue_info = {
|
let queue_info = {
|
||||||
let queue = ctrl.queue.lock_safe();
|
let queue = ctrl.queue.lock_safe();
|
||||||
format!(
|
format!(
|
||||||
@@ -912,93 +926,115 @@ 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 before next(): {}", queue_info);
|
||||||
|
|
||||||
// Emit queue changed event so frontend updates UI with new current track
|
match ctrl.next() {
|
||||||
ctrl.emit_queue_changed();
|
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) => {
|
Err(e) => {
|
||||||
log::error!("[Autoplay] Failed to advance to next track: {}", e);
|
log::error!("[Autoplay] Decision failed: {}", 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
Ok(AutoplayDecision::ShowNextEpisodePopup {
|
} else {
|
||||||
current_episode,
|
log::warn!("[Autoplay] PlayerController not initialized - emitting PlaybackEnded");
|
||||||
next_episode,
|
// Fallback: just emit PlaybackEnded event
|
||||||
countdown_seconds,
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
auto_advance,
|
emitter.emit(PlayerStatusEvent::PlaybackEnded);
|
||||||
}) => {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 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,
|
||||||
) {
|
) {
|
||||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
jni_guard(
|
||||||
emitter.emit(PlayerStatusEvent::Buffering {
|
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnBuffering",
|
||||||
percent: percent as u8,
|
|| {
|
||||||
});
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
}
|
emitter.emit(PlayerStatusEvent::Buffering {
|
||||||
|
percent: percent as u8,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when a playback error occurs.
|
/// Called when a playback error occurs.
|
||||||
@@ -1023,67 +1064,72 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
message: JString,
|
message: JString,
|
||||||
recoverable: jboolean,
|
recoverable: jboolean,
|
||||||
) {
|
) {
|
||||||
let message_str: String = env
|
jni_guard(
|
||||||
.get_string(&message)
|
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeOnError",
|
||||||
.map(|s| s.into())
|
|| {
|
||||||
.unwrap_or_else(|_| "Unknown error".to_string());
|
let message_str: String = env
|
||||||
let recoverable = recoverable != 0;
|
.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,
|
// 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
|
// 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 error stops playback for good (the frontend's handler calls
|
||||||
// player_stop); re-opening the stream where it died is the "buffer and
|
// player_stop); re-opening the stream where it died is the "buffer and
|
||||||
// resume" this actually is. Everything else keeps reporting the error.
|
// resume" this actually is. Everything else keeps reporting the error.
|
||||||
if recoverable {
|
if recoverable {
|
||||||
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
if let Some(controller) = PLAYER_CONTROLLER.get() {
|
||||||
let controller = controller.clone();
|
let controller = controller.clone();
|
||||||
let message_str = message_str.clone();
|
let message_str = message_str.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
let resume = controller.lock().await.recoverable_error_resume();
|
let resume = controller.lock().await.recoverable_error_resume();
|
||||||
let Some((position, delay_secs)) = resume else {
|
let Some((position, delay_secs)) = resume else {
|
||||||
// Declined here, so report it as NOT recoverable: the frontend
|
// Declined here, so report it as NOT recoverable: the frontend
|
||||||
// would otherwise echo it into player_recover_stream and ask
|
// would otherwise echo it into player_recover_stream and ask
|
||||||
// the same question a second time.
|
// the same question a second time.
|
||||||
if let Some(emitter) = EVENT_EMITTER.get() {
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
emitter.emit(PlayerStatusEvent::Error {
|
emitter.emit(PlayerStatusEvent::Error {
|
||||||
message: message_str,
|
message: message_str,
|
||||||
recoverable: false,
|
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;
|
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() {
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
emitter.emit(PlayerStatusEvent::Error {
|
emitter.emit(PlayerStatusEvent::Error {
|
||||||
message: message_str,
|
message: message_str,
|
||||||
recoverable,
|
recoverable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Called when volume changes.
|
/// Called when volume changes.
|
||||||
@@ -1094,16 +1140,21 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
|
|||||||
volume: jfloat,
|
volume: jfloat,
|
||||||
muted: jboolean,
|
muted: jboolean,
|
||||||
) {
|
) {
|
||||||
if let Some(state) = SHARED_STATE.get() {
|
jni_guard(
|
||||||
state.lock_safe().volume = volume;
|
"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() {
|
if let Some(emitter) = EVENT_EMITTER.get() {
|
||||||
emitter.emit(PlayerStatusEvent::VolumeChanged {
|
emitter.emit(PlayerStatusEvent::VolumeChanged {
|
||||||
volume,
|
volume,
|
||||||
muted: muted != 0,
|
muted: muted != 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// JNI callback for MediaSession commands from JellyTauPlaybackService
|
// JNI callback for MediaSession commands from JellyTauPlaybackService
|
||||||
@@ -1127,14 +1178,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
|
|||||||
_class: JClass,
|
_class: JClass,
|
||||||
command: JString,
|
command: JString,
|
||||||
) {
|
) {
|
||||||
let command_str: String = env
|
jni_guard(
|
||||||
.get_string(&command)
|
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnMediaCommand",
|
||||||
.map(|s| s.into())
|
|| {
|
||||||
.unwrap_or_default();
|
let command_str: String = env
|
||||||
|
.get_string(&command)
|
||||||
|
.map(|s| s.into())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
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,14 +1204,19 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlaybackServic
|
|||||||
command: JString,
|
command: JString,
|
||||||
volume: jint,
|
volume: jint,
|
||||||
) {
|
) {
|
||||||
let command_str: String = env
|
jni_guard(
|
||||||
.get_string(&command)
|
"Java_com_dtourolle_jellytau_player_JellyTauPlaybackService_nativeOnRemoteVolumeChange",
|
||||||
.map(|s| s.into())
|
|| {
|
||||||
.unwrap_or_default();
|
let command_str: String = env
|
||||||
|
.get_string(&command)
|
||||||
|
.map(|s| s.into())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
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,40 +1231,45 @@ 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,
|
||||||
) {
|
) {
|
||||||
let video_str: String = env
|
jni_guard(
|
||||||
.get_string(&video_codecs)
|
"Java_com_dtourolle_jellytau_player_JellyTauPlayer_00024Companion_nativeOnCodecsDetected",
|
||||||
.map(|s| s.into())
|
|| {
|
||||||
.unwrap_or_default();
|
let video_str: String = env
|
||||||
|
.get_string(&video_codecs)
|
||||||
|
.map(|s| s.into())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
let audio_str: String = env
|
let audio_str: String = env
|
||||||
.get_string(&audio_codecs)
|
.get_string(&audio_codecs)
|
||||||
.map(|s| s.into())
|
.map(|s| s.into())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Kotlin sends 0 when AudioCapabilities had no answer for the current route.
|
// 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 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!(
|
log::info!(
|
||||||
"[CodecDetection] Detected {} video codecs: {}",
|
"[CodecDetection] Detected {} video codecs: {}",
|
||||||
codecs.video_codecs.len(),
|
codecs.video_codecs.len(),
|
||||||
codecs.video_codecs_string()
|
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.
|
/// 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)]
|
#[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;
|
||||||
|
|||||||
Reference in New Issue
Block a user