iris-android-app: a panic hook, so an abort says something Iris can read

Checked before writing anything: under `panic = "abort"` (this crate's
Cargo.toml) a panic's message reaches the tombstone's `Abort message`
and nowhere else -- not `log`, so not `client_core::log_ring`, so not
Dev Updater's Runtime tab. That tab is the only surface Iris has on a
phone with no `adb`, so every `assert!` and `expect!` in these builds
has been failing silently as far as she is concerned; the adapter crash
fixed in the next commit looked like the app simply relaunching.

`install_panic_hook` (called from `app_log::install`) writes the
message and its location at `error` level. The ring is memory only and
the process is about to die, so it also writes `last-panic.txt` in the
app's private directory; `set_crash_dir`, called from
`nativeSetFilesDir`, replays that into the ring at `error` level on the
next start and deletes it. A crash loop therefore explains itself in
the run that is still up, which is the run somebody can look at.

Verified on this checkout's emulator against the unfixed renderer:
`iris panic at .../render.rs:140:14: Could not get adapter!: NotFound
{...}` on the run that died, and `iris app log: the previous run died
-- ...` on the next one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-07 21:49:45 -04:00
1 parent 203f53470c
commit f99ae4c366
2 files changed
+63 -1

No files matched your search

+59
View File
@@ -34,6 +34,7 @@ pub fn install(max_level: log::LevelFilter) {
// The line goes through whatever logger did win.
log::warn!("iris app log: a logger was already installed, so there is no ring");
}
install_panic_hook();
}
/// The process's ring -- what `Copy report` appends, what the diagnostics
@@ -63,3 +64,61 @@ pub fn diagnostics_line() -> String {
};
format!("{}\n{where_to_read}", ring().summary())
}
/// Where the panic hook leaves its one line, under the app's private
/// directory. Read back and dropped by [`set_crash_dir`] on the next
/// start.
const CRASH_FILE: &str = "last-panic.txt";
static CRASH_PATH: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
/// Installs a `log`-level panic hook, so a panic's message and location
/// reach the ring and `logcat` rather than only the tombstone.
///
/// **Why this is needed at all**: these builds are `panic = "abort"`
/// (`Cargo.toml`), and the default hook writes to `stderr` plus
/// `android_set_abort_message` -- the crash report. Iris runs these on a
/// phone with no `adb`, so the crash report is exactly the surface she
/// cannot read, and an `assert!` that fired said nothing anywhere she
/// could see it. Routing it through `log::error!` puts it in front of
/// `android_logger` *and* in the ring `devlog`'s provider hands to Dev
/// Updater.
///
/// The ring is memory only, so after an abort the process that holds it
/// is gone -- hence the file half. [`set_crash_dir`] replays it.
fn install_panic_hook() {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let where_at = match info.location() {
Some(at) => format!("{}:{}:{}", at.file(), at.line(), at.column()),
None => "an unknown location".to_string(),
};
// `info`'s own `Display` repeats the location and a newline;
// the payload alone keeps this to the one line the ring wants.
let message = info.payload_as_str().unwrap_or("Box<dyn Any>");
let line = format!("iris panic at {where_at}: {message}");
log::error!("{line}");
if let Some(path) = CRASH_PATH.get() {
// Best effort by design: a panic is already the failure, and
// failing to record it must not become a second one.
let _ = std::fs::write(path, &line);
}
previous(info);
}));
}
/// Tells the panic hook where to leave its line, and replays the line a
/// previous run left there into the ring before deleting it.
///
/// Called from `nativeSetFilesDir`, which is the first moment the app's
/// private directory is known. The replay is at `error` level and says
/// it is from the previous run, so a crash loop shows the reason it is
/// looping in the Runtime tab of the run that is still up.
pub fn set_crash_dir(dir: &std::path::Path) {
let path = dir.join(CRASH_FILE);
if let Ok(previous) = std::fs::read_to_string(&path) {
log::error!("iris app log: the previous run died -- {}", previous.trim());
let _ = std::fs::remove_file(&path);
}
let _ = CRASH_PATH.set(path);
}
+4 -1
View File
@@ -180,7 +180,10 @@ pub extern "system" fn Java_dev_iris_android_demo_MainActivity_nativeSetFilesDir
return;
};
#[cfg(feature = "transcript-screen")]
enrollment::set_files_dir(std::path::PathBuf::from(&dir));
{
app_log::set_crash_dir(std::path::Path::new(&dir));
enrollment::set_files_dir(std::path::PathBuf::from(&dir));
}
log::debug!("iris app: files directory is {dir}");
}