Skip to main content

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}