jellytau_lib/player/jni_guard.rs
1//! Panic containment for the Android JNI boundary.
2//!
3//! Compiled on every platform, unlike `player::android` itself, so the guard and
4//! the tripwire that enforces its use are unit-tested on the host — the same
5//! reason `RESUME_BACKOFF_STEP_SECS` lives outside the `cfg(android)` block.
6
7/// Run the body of a JNI callback with any panic contained.
8///
9/// Every `extern "system"` function in this file is called by the JVM on an
10/// arbitrary thread. A panic that unwinds out of one crosses the FFI boundary,
11/// which Rust answers by **aborting the process** — the app vanishes with no
12/// Java exception, no stack trace attributable to it, and no crash report the
13/// user can send. That is the worst possible failure mode for the callbacks
14/// that fire four times a second during playback.
15///
16/// The panics are real, not theoretical: this file builds a fallback Tokio
17/// runtime on threads that have none, and `Runtime::new()` fails under the fd
18/// exhaustion and thread-spawn refusal an Android device puts a media app
19/// through. Losing one position report is recoverable; losing the process is
20/// not.
21///
22/// A contained panic still leaves whatever it interrupted half-done, so this is
23/// a backstop, not a licence to panic. `utils::lock` already keeps a poisoned
24/// mutex from cascading; this keeps the FFI boundary from turning any remaining
25/// panic into a process kill.
26///
27/// TRACES: UR-005 | DR-052
28///
29/// Only *called* from `player::android`, which is `cfg(target_os = "android")`,
30/// so it is dead code on every other target — the same reason
31/// `RESUME_BACKOFF_STEP_SECS` carries this attribute. It is still compiled and
32/// tested here on purpose.
33#[cfg_attr(not(target_os = "android"), allow(dead_code))]
34pub(crate) fn jni_guard<F: FnOnce()>(name: &str, body: F) {
35 // AssertUnwindSafe: the shared state behind these callbacks is already
36 // reached through poison-tolerant locks, so a panic cannot hand out a
37 // guard observing a torn value.
38 if std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)).is_err() {
39 // The panic hook has already logged the payload and location.
40 log::error!("[JNI] Panic in {name} was contained; the callback was dropped");
41 }
42}
43
44// TRACES: UR-005 | DR-052 | UT-052
45#[cfg(test)]
46mod jni_guard_tests {
47 use super::*;
48
49 /// The guard must swallow a panic rather than let it reach the JVM.
50 ///
51 /// TRACES: UR-005 | DR-052 | UT-052
52 #[test]
53 fn a_panicking_callback_body_does_not_escape_the_guard() {
54 let hook = std::panic::take_hook();
55 std::panic::set_hook(Box::new(|_| {}));
56 jni_guard("test_callback", || panic!("ExoPlayer callback blew up"));
57 std::panic::set_hook(hook);
58 // Reaching here at all is the assertion: without the guard the panic
59 // would unwind out of the `extern "system"` fn and abort the process.
60 }
61
62 /// The guard must not disturb a callback that behaves.
63 ///
64 /// TRACES: UR-005 | DR-052 | UT-052
65 #[test]
66 fn a_normal_callback_body_still_runs() {
67 let mut ran = false;
68 jni_guard("test_callback", || ran = true);
69 assert!(ran);
70 }
71
72 /// **Tripwire.** Every JNI entry point must wrap its body in `jni_guard`.
73 ///
74 /// A panic crossing the `extern "system"` boundary aborts the process, so a
75 /// twelfth callback added without the guard reintroduces the whole defect.
76 /// Checked against the source because the real boundary needs a JVM to
77 /// exercise — the same tripwire idiom as `check:boundary`.
78 ///
79 /// TRACES: UR-005 | DR-052 | UT-052
80 #[test]
81 fn every_jni_entry_point_wraps_its_body_in_the_guard() {
82 let src = include_str!("android/mod.rs");
83 let mut unguarded = Vec::new();
84
85 let mut lines = src.lines().enumerate().peekable();
86 while let Some((_, line)) = lines.next() {
87 if !line.starts_with("pub extern \"system\" fn ") {
88 continue;
89 }
90 let name = line
91 .trim_start_matches("pub extern \"system\" fn ")
92 .trim_end_matches('(')
93 .to_string();
94
95 // Walk to the end of the parameter list, then look at the first
96 // statement of the body.
97 let mut body_start = None;
98 for (n, l) in lines.by_ref() {
99 if l.trim_end().ends_with(") {") || l.trim() == ") {" {
100 body_start = Some(n);
101 break;
102 }
103 }
104 assert!(body_start.is_some(), "could not find the body of {name}");
105
106 match lines.peek() {
107 Some((_, first)) if first.trim_start().starts_with("jni_guard(") => {}
108 other => unguarded.push(format!(
109 "{name} (body starts with {:?})",
110 other.map(|(_, l)| l.trim()).unwrap_or("<eof>")
111 )),
112 }
113 }
114
115 assert!(
116 unguarded.is_empty(),
117 "JNI entry points whose body is not wrapped in jni_guard — a panic in \
118 one of these aborts the process:\n {}",
119 unguarded.join("\n ")
120 );
121 }
122}