Never crash: frame-pool panic handler and ui-egui without expect()

ui-egui without expect()/unreachable!(): the frame pool is optional (rayon's global pool if the
OS refuses threads) and gets a panic_handler, because a panic in a rayon spawn() job aborts the
whole process by default; render-queue progress, the text-edit anchor and the GPU viewer
texture are bound where they're checked; the settings panel's Bool arm no longer panics.
Denies the panic lints in the crate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-04 14:52:28 -04:00
parent 10644a83aa
commit 543942d085
7 changed files with 27 additions and 10 deletions

4
clippy.toml Normal file
View File

@ -0,0 +1,4 @@
allow-unwrap-in-tests = true
allow-expect-in-tests = true
allow-panic-in-tests = true
allow-indexing-slicing-in-tests = true

View File

@ -232,7 +232,8 @@ pub struct Frames {
/// Pending jobs, picked by priority when a pool thread frees up (the viewer's frame first). /// Pending jobs, picked by priority when a pool thread frees up (the viewer's frame first).
queue: Arc<Mutex<Queue>>, queue: Arc<Mutex<Queue>>,
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pool: rayon::ThreadPool, /// None if the OS refused the threads: jobs then go to rayon's global pool.
pool: Option<rayon::ThreadPool>,
ctx: Option<egui::Context>, ctx: Option<egui::Context>,
/// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout. /// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout.
pub last_ms: Arc<Mutex<f64>>, pub last_ms: Arc<Mutex<f64>>,
@ -251,7 +252,14 @@ impl Default for Frames {
inflight: Arc::new(Mutex::new(HashSet::new())), inflight: Arc::new(Mutex::new(HashSet::new())),
queue: Arc::new(Mutex::new(Queue::default())), queue: Arc::new(Mutex::new(Queue::default())),
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pool: rayon::ThreadPoolBuilder::new().num_threads(threads).thread_name(|i| format!("ec-frame-{i}")).build().expect("frame pool"), // A panicking job would abort the whole process (rayon's default for `spawn`): log it
// and lose only that frame (the panic hook has logged the message).
pool: rayon::ThreadPoolBuilder::new()
.num_threads(threads)
.thread_name(|i| format!("ec-frame-{i}"))
.panic_handler(|_| log::error!("a frame render panicked; that frame was skipped"))
.build()
.ok(),
ctx: None, ctx: None,
last_ms: Arc::new(Mutex::new(0.0)), last_ms: Arc::new(Mutex::new(0.0)),
content_keys: Arc::default(), content_keys: Arc::default(),
@ -405,9 +413,13 @@ impl Frames {
if self.remote.is_none() { if self.remote.is_none() {
let w = self.worker(); let w = self.worker();
// One spawn per job; each spawn renders whichever queued job matters most right now. // One spawn per job; each spawn renders whichever queued job matters most right now.
self.pool.spawn(move || { let job = move || {
w.run_next(); w.run_next();
}); };
match &self.pool {
Some(pool) => pool.spawn(job),
None => rayon::spawn(job),
}
} }
#[cfg(target_arch = "wasm32")] #[cfg(target_arch = "wasm32")]
if let Some(ctx) = &self.ctx { if let Some(ctx) = &self.ctx {

View File

@ -5,6 +5,8 @@
//! Everything goes through `effectcraft_engine::Session::execute` (or `menus::invoke` for //! Everything goes through `effectcraft_engine::Session::execute` (or `menus::invoke` for
//! frontend-only commands) so every gesture is also available to agents. //! frontend-only commands) so every gesture is also available to agents.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod audio; pub mod audio;
pub mod automation; pub mod automation;
pub mod bench; pub mod bench;

View File

@ -570,8 +570,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
lp.text(pos2(cx_num + 2.0, row.center().y), Align2::LEFT_CENTER, n.to_string(), Tokens::ui(12.0), t.text_dim); lp.text(pos2(cx_num + 2.0, row.center().y), Align2::LEFT_CENTER, n.to_string(), Tokens::ui(12.0), t.text_dim);
let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into()); let name = comp_item.map(|i| i.name.clone()).unwrap_or_else(|| "(missing composition)".into());
lp.text(pos2(cx_name + 2.0, row.center().y), Align2::LEFT_CENTER, &name, Tokens::ui(12.0), t.text); lp.text(pos2(cx_name + 2.0, row.center().y), Align2::LEFT_CENTER, &name, Tokens::ui(12.0), t.text);
let status = if rendering && progress.as_ref().and_then(|p| p.current) == Some(it.id) { let status = if rendering && let Some(pr) = progress.as_ref().filter(|p| p.current == Some(it.id)) {
let pr = progress.as_ref().expect("progress");
format!("Rendering {:.0}%", if pr.total > 0 { pr.done as f64 * 100.0 / pr.total as f64 } else { 0.0 }) format!("Rendering {:.0}%", if pr.total > 0 { pr.done as f64 * 100.0 / pr.total as f64 } else { 0.0 })
} else { } else {
it.status.label().to_string() it.status.label().to_string()

View File

@ -176,7 +176,8 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
} }
r r
} }
Kind::Bool => unreachable!(), // Checkboxes are drawn above; nothing to edit here.
Kind::Bool => ui.label(""),
}; };
resp = Some(r); resp = Some(r);
}); });

View File

@ -193,8 +193,7 @@ pub fn hook(
handled = true; handled = true;
} else if e.contains(map, pos) { } else if e.contains(map, pos) {
let ci = e.hit(map, pos); let ci = e.hit(map, pos);
let sel = editing.as_ref().expect("editing"); let anchor = if mods.shift { editing.as_ref().map_or(ci, |sel| sel.anchor) } else { ci };
let anchor = if mods.shift { sel.anchor } else { ci };
exec(app, "text.setSelection", json!({"anchor": anchor, "caret": ci})); exec(app, "text.setSelection", json!({"anchor": anchor, "caret": ci}));
typing_key(ui, true); typing_key(ui, true);
drag = Some(Drag::Select { layer: e.layer.id }); drag = Some(Drag::Select { layer: e.layer.id });

View File

@ -380,7 +380,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
// read it back first (asynchronously in the browser: from the next frame on). // read it back first (asynchronously in the browser: from the next frame on).
let gpu = matches!((&app.viewer_shown, &app.viewer_native), (Some(s), Some(n)) if s.0 == n.0 && s.1 == n.1 && n.1.comp == cid.0); let gpu = matches!((&app.viewer_shown, &app.viewer_native), (Some(s), Some(n)) if s.0 == n.0 && s.1 == n.1 && n.1.comp == cid.0);
let rect = if gpu { let rect = if gpu {
let id = app.viewer_shown.map(|s| s.0).expect("shown"); let Some(id) = app.viewer_shown.map(|s| s.0) else { return };
if plain || app.viewer_pixels().is_none() { if plain || app.viewer_pixels().is_none() {
if !plain { if !plain {
ctx.request_repaint(); ctx.request_repaint();