Files
effectcraft/crates/engine/src/guard.rs
T
Brandon ThomasandClaude Opus 5.5 6144ca0804 Never crash: last-resort guard around command execution, and a logging panic hook
Session::execute runs every command (UI, CLI, control channel, MCP, scripts; file import,
export and save are commands too) through guard::guarded, which catch_unwinds a panic that
escapes the command and returns EngineError::Other("command `x` failed with an internal error
(...); it was stopped and your project is still open"). Edits work on a copy of the project, so
a command that panics mid-edit leaves the project untouched. The desktop app also installs a
panic hook that logs every panic through the app logger (then runs the default hook).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 13:27:33 -04:00

66 lines
2.5 KiB
Rust

//! The last-resort guard: a panic that escapes a command (a bug, never an expected error) is
//! turned into an [`EngineError`] instead of taking the app, and the user's open project, down
//! with it. Edits work on a copy of the project (see [`crate::Session::edit`]), so an edit that
//! panics leaves the project as it was. This is a safety net, not a substitute for returning
//! errors (AGENTS.md, "Never crash").
use crate::{EngineError, Result};
/// Run `f`; a panic inside it becomes an error naming `what`. The panic itself is logged by
/// the panic hook ([`crate::logging::install_panic_hook`]).
pub fn guarded<T>(what: &str, f: impl FnOnce() -> Result<T>) -> Result<T> {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
Ok(r) => r,
Err(payload) => {
let msg = payload
.downcast_ref::<&str>()
.map(|s| s.to_string())
.or_else(|| payload.downcast_ref::<String>().cloned())
.unwrap_or_else(|| "unknown error".into());
log::error!("{what} panicked: {msg}");
Err(EngineError::Other(format!("{what} failed with an internal error ({msg}); it was stopped and your project is still open")))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_panic_becomes_an_error() {
let r: Result<()> = guarded("command `test.panic`", || panic!("boom"));
let e = r.unwrap_err().to_string();
assert!(e.contains("test.panic") && e.contains("boom"), "{e}");
let r: Result<u8> = guarded("x", || {
let v: Vec<u8> = vec![];
Ok(v[3])
});
assert!(r.is_err());
assert_eq!(guarded("ok", || Ok(7)).unwrap(), 7);
assert!(matches!(guarded::<()>("err", || Err(EngineError::NoComp)), Err(EngineError::NoComp)));
}
#[test]
fn the_session_survives_a_panicking_edit() {
let mut s = crate::Session::new();
let before = s.project.clone();
let r: Result<()> = guarded("command `test.edit`", || {
s.edit("Broken", None, |p, _| {
p.next_id += 1;
panic!("bug in the middle of an edit")
})
});
assert!(r.is_err());
assert!(std::sync::Arc::ptr_eq(&before, &s.project), "the project is untouched");
// Later edits still work.
let next = s.project.next_id;
s.edit("Fine", None, |p, _| {
p.next_id += 1;
Ok(())
})
.unwrap();
assert_eq!(s.project.next_id, next + 1);
}
}