Architecture remediation A/B/F: poison-tolerant locks, graceful backend init, doc fixes

Workstream A — poison-tolerant locking:
- Add utils/lock.rs with MutexSafe/RwLockSafe extension traits that recover a
  poisoned std::sync lock instead of panicking, plus unit tests.
- Replace all 153 .lock().unwrap() and 4 .read()/.write().unwrap() production
  sites with _safe variants across 14 files, eliminating the player
  crash-cascade class. Tokio async mutexes are unchanged.

Workstream B — graceful backend init:
- create_player_backend no longer panics when MPV/ExoPlayer fail to initialize;
  it falls back to NullBackend and emits a backend-init-failed event so the UI
  can show "playback unavailable" instead of the app crashing. Fatal DB-setup
  panics are kept.

Workstream F — doc reconciliation:
- Rewrite software-architecture.md's inaccurate "thin UI / ~800 lines" claims to
  reflect reality (~20.5k non-test frontend) and document the events+polling
  hybrid plus the new locking/backend-init behavior.
This commit is contained in:
2026-06-20 16:03:54 +02:00
parent 0738ef10ec
commit 6866f03c55
18 changed files with 345 additions and 178 deletions
+14 -13
View File
@@ -3,6 +3,7 @@
//! This module provides a `PlayerBackend` implementation using Android's ExoPlayer
//! through JNI calls to Kotlin code.
use crate::utils::lock::MutexSafe;
use std::sync::{Arc, Mutex, OnceLock};
use tokio::sync::Mutex as TokioMutex;
use log::debug;
@@ -303,7 +304,7 @@ impl PlayerBackend for ExoPlayerBackend {
// Update local state
{
let mut state = self.shared_state.lock().unwrap();
let mut state = self.shared_state.lock_safe();
state.current_media = Some(media.clone());
state.state = PlayerState::Loading {
media: media.clone(),
@@ -430,7 +431,7 @@ impl PlayerBackend for ExoPlayerBackend {
fn stop(&mut self) -> Result<(), PlayerError> {
{
let mut state = self.shared_state.lock().unwrap();
let mut state = self.shared_state.lock_safe();
state.state = PlayerState::Idle;
state.is_loaded = false;
state.current_media = None;
@@ -479,24 +480,24 @@ impl PlayerBackend for ExoPlayerBackend {
)
.map_err(|e| PlayerError::playback_failed(format!("Failed to call setVolume: {}", e)))?;
self.shared_state.lock().unwrap().volume = clamped;
self.shared_state.lock_safe().volume = clamped;
Ok(())
}
fn position(&self) -> f64 {
self.shared_state.lock().unwrap().position
self.shared_state.lock_safe().position
}
fn duration(&self) -> Option<f64> {
self.shared_state.lock().unwrap().duration
self.shared_state.lock_safe().duration
}
fn state(&self) -> PlayerState {
self.shared_state.lock().unwrap().state.clone()
self.shared_state.lock_safe().state.clone()
}
fn volume(&self) -> f32 {
self.shared_state.lock().unwrap().volume
self.shared_state.lock_safe().volume
}
fn set_audio_track(&mut self, stream_index: i32) -> Result<(), PlayerError> {
@@ -564,7 +565,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// 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().unwrap();
let mut state = state.lock_safe();
state.position = position;
if duration > 0.0 {
state.duration = Some(duration);
@@ -606,7 +607,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Update shared state
if let Some(shared) = SHARED_STATE.get() {
let mut shared = shared.lock().unwrap();
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;
@@ -651,7 +652,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
duration: jdouble,
) {
if let Some(state) = SHARED_STATE.get() {
let mut state = state.lock().unwrap();
let mut state = state.lock_safe();
state.duration = Some(duration);
state.is_loaded = true;
}
@@ -692,7 +693,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
// Log queue state before advancing
let queue_info = {
let queue = ctrl.queue.lock().unwrap();
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);
@@ -702,7 +703,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
log::info!("[Autoplay] Successfully advanced to next track");
// Log queue state after advancing
let queue_info = {
let queue = ctrl.queue.lock().unwrap();
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);
@@ -811,7 +812,7 @@ pub extern "system" fn Java_com_dtourolle_jellytau_player_JellyTauPlayer_nativeO
muted: jboolean,
) {
if let Some(state) = SHARED_STATE.get() {
state.lock().unwrap().volume = volume;
state.lock_safe().volume = volume;
}
if let Some(emitter) = EVENT_EMITTER.get() {