use std::panic::{AssertUnwindSafe, catch_unwind};
#[cfg(panic = "abort")]
compile_error!(
"plushie requires `panic = \"unwind\"` because catch_unwind in \
run_guarded_view / run_guarded_update is load-bearing for the \
SDK's view+update panic recovery and the frozen-UI overlay. \
Building with `panic = \"abort\"` would silently make this a no-op."
);
use plushie_core::protocol::{PropMap, PropValue, Props, TreeNode};
use crate::App;
use crate::command::Command;
use crate::event::Event;
#[cfg(feature = "direct")]
use crate::runtime::prepare_tree;
#[cfg(feature = "wire")]
use crate::widget::WidgetRegistrar;
#[cfg(feature = "direct")]
use crate::widget::WidgetStateStore;
pub const VIEW_ERROR_THRESHOLD: u32 = 5;
const FROZEN_OVERLAY_ID: &str = "__plushie_frozen_ui_overlay__";
#[derive(Debug, Default)]
pub struct ViewErrors {
pub consecutive: u32,
pub overlay_active: bool,
}
pub enum ViewOutcome {
Ok(TreeNode, Vec<plushie_core::Diagnostic>),
Panicked {
last_good: TreeNode,
#[allow(dead_code)]
consecutive: u32,
#[allow(dead_code)]
message: String,
},
}
pub enum UpdateOutcome {
Ok(Command),
Panicked {
cmd: Command,
#[allow(dead_code)]
consecutive: u32,
#[allow(dead_code)]
message: String,
},
}
#[cfg(feature = "direct")]
pub fn run_guarded_view<A: App>(
state: &mut ViewErrors,
model: &A::Model,
widget_store: &mut WidgetStateStore,
memo_cache: &mut crate::runtime::MemoCache,
widget_view_cache: &mut crate::runtime::WidgetViewCache,
last_good: &TreeNode,
) -> ViewOutcome {
let result = catch_unwind(AssertUnwindSafe(|| {
prepare_tree::<A>(model, widget_store, memo_cache, widget_view_cache)
}));
match result {
Ok((tree, warnings)) => {
state.consecutive = 0;
state.overlay_active = false;
ViewOutcome::Ok(tree, warnings)
}
Err(payload) => {
let message = panic_payload_message(&*payload);
state.consecutive = state.consecutive.saturating_add(1);
let diag = plushie_core::Diagnostic::ViewPanicked {
consecutive: state.consecutive,
message: message.clone(),
};
log::error!("{diag}");
let tree = if state.consecutive >= VIEW_ERROR_THRESHOLD && !state.overlay_active {
state.overlay_active = true;
inject_overlay(last_good)
} else {
last_good.clone()
};
ViewOutcome::Panicked {
last_good: tree,
consecutive: state.consecutive,
message,
}
}
}
}
#[cfg(feature = "wire")]
pub fn run_guarded_view_wire<A: App>(
state: &mut ViewErrors,
model: &A::Model,
last_good: &TreeNode,
) -> ViewOutcome {
let result = catch_unwind(AssertUnwindSafe(|| {
let mut registrar = WidgetRegistrar::new();
let view = A::view(model, &mut registrar).into_tree_node();
crate::runtime::normalize::normalize(&view)
}));
match result {
Ok((tree, warnings)) => {
state.consecutive = 0;
state.overlay_active = false;
ViewOutcome::Ok(tree, warnings)
}
Err(payload) => {
let message = panic_payload_message(&*payload);
state.consecutive = state.consecutive.saturating_add(1);
let diag = plushie_core::Diagnostic::ViewPanicked {
consecutive: state.consecutive,
message: message.clone(),
};
log::error!("{diag}");
let tree = if state.consecutive >= VIEW_ERROR_THRESHOLD && !state.overlay_active {
state.overlay_active = true;
inject_overlay(last_good)
} else {
last_good.clone()
};
ViewOutcome::Panicked {
last_good: tree,
consecutive: state.consecutive,
message,
}
}
}
}
pub fn run_guarded_update<A: App>(
state: &mut ViewErrors,
model: &mut A::Model,
event: Event,
) -> UpdateOutcome {
let result = catch_unwind(AssertUnwindSafe(|| A::update(model, event)));
match result {
Ok(cmd) => UpdateOutcome::Ok(cmd),
Err(payload) => {
let message = panic_payload_message(&*payload);
state.consecutive = state.consecutive.saturating_add(1);
let diag = plushie_core::Diagnostic::UpdatePanicked {
consecutive: state.consecutive,
message: message.clone(),
};
log::error!("{diag}");
UpdateOutcome::Panicked {
cmd: Command::None,
consecutive: state.consecutive,
message,
}
}
}
}
fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(s) = payload.downcast_ref::<&'static str>() {
(*s).to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
"<non-string panic payload>".to_string()
}
}
fn inject_overlay(tree: &TreeNode) -> TreeNode {
let overlay = build_overlay_node();
let mut new_tree = tree.clone();
attach_overlay(&mut new_tree, &overlay);
new_tree
}
fn build_overlay_node() -> TreeNode {
let mut container_props = PropMap::new();
container_props.insert("background", PropValue::Str("#b91c1c".to_string()));
container_props.insert("padding", PropValue::F64(12.0));
let mut text_props = PropMap::new();
text_props.insert(
"value",
PropValue::Str("UI is not updating. Check error logs for details.".to_string()),
);
text_props.insert("color", PropValue::Str("#ffffff".to_string()));
let text_node = TreeNode {
id: String::new(),
type_name: "text".to_string(),
props: Props::from(text_props),
children: vec![],
};
TreeNode {
id: FROZEN_OVERLAY_ID.to_string(),
type_name: "container".to_string(),
props: Props::from(container_props),
children: vec![text_node],
}
}
fn attach_overlay(tree: &mut TreeNode, overlay: &TreeNode) {
if tree.type_name == "window" {
tree.children.push(overlay.clone());
return;
}
let mut attached = false;
for child in &mut tree.children {
if child.type_name == "window" {
child.children.push(overlay.clone());
attached = true;
}
}
if !attached {
tree.children.push(overlay.clone());
}
}
#[cfg(test)]
mod tests {
use super::*;
fn window_node(id: &str) -> TreeNode {
TreeNode {
id: id.to_string(),
type_name: "window".to_string(),
props: Props::from(PropMap::new()),
children: vec![],
}
}
fn overlay_count(node: &TreeNode) -> usize {
let mut n = if node.id == FROZEN_OVERLAY_ID { 1 } else { 0 };
for child in &node.children {
n += overlay_count(child);
}
n
}
#[test]
fn inject_overlay_attaches_to_every_window() {
let tree = TreeNode {
id: "root".to_string(),
type_name: "container".to_string(),
props: Props::from(PropMap::new()),
children: vec![
window_node("main"),
window_node("secondary"),
window_node("tertiary"),
],
};
let result = inject_overlay(&tree);
assert_eq!(overlay_count(&result), 3, "one overlay per window");
for child in &result.children {
let overlay_children: Vec<&TreeNode> = child
.children
.iter()
.filter(|c| c.id == FROZEN_OVERLAY_ID)
.collect();
assert_eq!(
overlay_children.len(),
1,
"window {:?} should carry exactly one overlay",
child.id
);
}
}
#[test]
fn inject_overlay_falls_through_when_no_windows() {
let tree = TreeNode {
id: "root".to_string(),
type_name: "column".to_string(),
props: Props::from(PropMap::new()),
children: vec![],
};
let result = inject_overlay(&tree);
assert_eq!(overlay_count(&result), 1);
assert_eq!(result.children.len(), 1);
assert_eq!(result.children[0].id, FROZEN_OVERLAY_ID);
}
#[test]
fn inject_overlay_handles_root_window() {
let tree = window_node("only");
let result = inject_overlay(&tree);
assert_eq!(overlay_count(&result), 1);
assert_eq!(result.type_name, "window");
assert_eq!(result.children.len(), 1);
assert_eq!(result.children[0].id, FROZEN_OVERLAY_ID);
}
use crate::App;
use crate::command::Command;
use crate::event::{Event, WidgetEvent};
use crate::widget::WidgetRegistrar;
struct BoomApp;
impl App for BoomApp {
type Model = Self;
fn init() -> (Self, Command) {
(BoomApp, Command::None)
}
fn update(_model: &mut Self::Model, event: Event) -> Command {
if let Event::Widget(WidgetEvent { scoped_id, .. }) = &event
&& scoped_id.id == "boom"
{
panic!("update boom");
}
Command::None
}
fn view(_model: &Self::Model, _widgets: &mut WidgetRegistrar) -> crate::ViewList {
crate::View::empty().into()
}
}
fn boom_event() -> Event {
Event::Widget(WidgetEvent {
event_type: plushie_core::EventType::Click,
scoped_id: plushie_core::ScopedId::new("boom".to_string(), Vec::new(), None),
value: serde_json::Value::Null,
})
}
fn benign_event() -> Event {
Event::Widget(WidgetEvent {
event_type: plushie_core::EventType::Click,
scoped_id: plushie_core::ScopedId::new("ok".to_string(), Vec::new(), None),
value: serde_json::Value::Null,
})
}
#[test]
fn run_guarded_update_catches_panic() {
let mut state = ViewErrors::default();
let mut model = BoomApp;
match run_guarded_update::<BoomApp>(&mut state, &mut model, boom_event()) {
UpdateOutcome::Panicked { message, .. } => {
assert!(
message.contains("update boom"),
"expected panic message, got {message:?}"
);
}
UpdateOutcome::Ok(_) => panic!("expected panic to be caught"),
}
assert_eq!(state.consecutive, 1);
}
#[test]
fn run_guarded_update_passes_ok_through() {
let mut state = ViewErrors {
consecutive: 7,
..ViewErrors::default()
};
let mut model = BoomApp;
match run_guarded_update::<BoomApp>(&mut state, &mut model, benign_event()) {
UpdateOutcome::Ok(_) => {}
UpdateOutcome::Panicked { .. } => panic!("benign event should not panic"),
}
assert_eq!(state.consecutive, 7);
}
#[test]
fn update_panics_share_counter_with_view() {
let mut state = ViewErrors::default();
let mut model = BoomApp;
for _ in 0..VIEW_ERROR_THRESHOLD {
let _ = run_guarded_update::<BoomApp>(&mut state, &mut model, boom_event());
}
assert_eq!(state.consecutive, VIEW_ERROR_THRESHOLD);
assert!(
!state.overlay_active,
"update guard should not flip the overlay flag directly; \
the view guard owns overlay injection"
);
}
}