Compare commits

...
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 63b589318a Never crash: last-resort guard for commands and file import/export
- Session::execute wraps a command's run in catch_unwind: a panic that
  escapes anyway becomes an EngineError ("`id` failed with an internal
  error (logged); the document is unchanged"). Session::edit only commits
  after its closure returns, so documents stay intact. This covers the
  UI, CLI, MCP and control channels.
- apps/photocraft: a new crash_guard module installs a logging panic hook
  (keeping the default one) and guards the native import/export services,
  so a panic while opening or exporting a file is reported as an error
  and the open documents stay. Wired in with two lines in main.rs.
- services::import_flat returns an error instead of unwrap; photocraft
  and photocraft-web deny unwrap/expect/panic/unimplemented/todo/
  unreachable.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-04 13:40:21 -04:00
5 changed files with 50 additions and 4 deletions

No files matched your search

+2
View File
@@ -14,6 +14,8 @@
//! URL query flags: `?cpu` forces the CPU canvas path (same as `PHOTOCRAFT_CPU_CANVAS=1`);
//! `?webgl` forces the WebGL2 backend instead of WebGPU.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#[cfg(target_arch = "wasm32")]
mod web;
+35
View File
@@ -0,0 +1,35 @@
//! Last-resort crash guard (AGENTS.md, Never crash).
//!
//! Code must return errors, never panic. This is the safety net for a panic that escapes anyway:
//! the hook logs it, and [`guard`] turns a panic during file import/export into an error the UI
//! shows, so the open documents stay in place. Commands get the same treatment in
//! `Session::execute`.
use std::panic::{AssertUnwindSafe, catch_unwind};
/// Log every panic (with its location) before the default hook prints it.
pub fn install_hook() {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
log::error!("PhotoCraft internal error: {info}");
default(info);
}));
}
/// Run `f`; a panic inside it becomes an `Err` naming `what`.
pub fn guard<T>(what: &str, f: impl FnOnce() -> Result<T, String>) -> Result<T, String> {
catch_unwind(AssertUnwindSafe(f)).unwrap_or_else(|_| Err(format!("{what} failed with an internal error (logged); your open documents are unchanged")))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn guard_turns_a_panic_into_an_error() {
let r: Result<(), String> = guard("Open", || panic!("boom"));
assert!(r.unwrap_err().contains("Open failed"));
assert_eq!(guard("Open", || Ok(3)), Ok(3));
assert_eq!(guard::<()>("Open", || Err("bad file".into())), Err("bad file".into()));
}
}
+3
View File
@@ -9,8 +9,10 @@
// Release builds on Windows are GUI-subsystem apps, so launching from the Start Menu or Explorer
// doesn't open a console window. (`--version` output then only shows when redirected.)
#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")]
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod control_server;
mod crash_guard;
mod services;
use photocraft_engine::Session;
@@ -30,6 +32,7 @@ fn app_icon() -> egui::IconData {
}
fn main() -> eframe::Result {
crash_guard::install_hook();
let mut control_port: Option<u16> = std::env::var("PHOTOCRAFT_CONTROL_PORT").ok().and_then(|p| p.parse().ok());
let mut files = Vec::new();
let mut args = std::env::args().skip(1);
+5 -3
View File
@@ -54,13 +54,15 @@ pub fn native() -> Services {
let savers: Rc<RefCell<HashMap<u64, Autosaver>>> = Rc::default();
let savers2 = savers.clone();
Services {
import: Some(Box::new(|name: &str, bytes: &[u8]| photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| e.to_string()))),
import: Some(Box::new(|name: &str, bytes: &[u8]| {
crate::crash_guard::guard("Open", || photocraft_io::import(name, bytes).map(|r| r.document).map_err(|e| e.to_string()))
})),
export: Some(Box::new(|doc: &Document, path: &str, settings: &photocraft_ui_egui::ExportSettings| {
let mut opts = photocraft_io::ExportOptions::default();
if let Some(q) = settings.jpeg_quality {
opts.encode.jpeg_quality = q;
}
photocraft_io::export(doc, path, &opts).map(|r| r.bytes).map_err(|e| e.to_string())
crate::crash_guard::guard("Export", || photocraft_io::export(doc, path, &opts).map(|r| r.bytes).map_err(|e| e.to_string()))
})),
pick_open: Some(Box::new(|| {
let path = rfd::FileDialog::new().add_filter("Images", IMAGE_EXTS).pick_file()?;
@@ -168,7 +170,7 @@ pub fn import_flat(name: &str, bytes: &[u8]) -> Result<Document, String> {
}
let mut layer = Layer::raster("Background", doc.pixel_format());
let data = conv.to_normalized();
layer.surface_mut().unwrap().write_region(Rect::from_xywh(0, 0, w, h), &data);
layer.surface_mut().ok_or("new raster layer has no pixels")?.write_region(Rect::from_xywh(0, 0, w, h), &data);
doc.layers.push(layer);
Ok(doc)
}
+5 -1
View File
@@ -294,7 +294,11 @@ impl Session {
// Pixel commands follow the Channels panel target unless the caller names one.
let run_params = channel_cmds::inject_target(self, id, commands::inject_kind(id, params.clone()));
self.color_restrict = channel_cmds::color_restriction(self, id, &run_params);
let r = (spec.run)(self, &run_params);
// Last-resort guard (AGENTS.md, Never crash): a command that panics anyway fails with an
// error instead of taking the app down. `edit` only commits a document after its closure
// returns, so the documents are unchanged.
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, &run_params)))
.unwrap_or_else(|_| Err(EngineError::Other(format!("`{id}` failed with an internal error (logged); the document is unchanged"))));
self.coalesce_request = None;
self.color_restrict = None;
let r = r?;