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
+4
View File
@@ -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;