use std::any::Any;
use std::collections::HashMap;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicU32, Ordering};
use iced::{Element, Theme};
use serde_json::Value;
use crate::image_registry::ImageRegistry;
use crate::message::Message;
use crate::protocol::{OutgoingEvent, TreeNode};
use crate::widgets::WidgetCaches;
pub(crate) fn catch_unwind_enabled() -> bool {
static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ENABLED.get_or_init(|| {
#[cfg(not(target_arch = "wasm32"))]
{
std::env::var("PLUSHIE_NO_CATCH_UNWIND").is_err()
}
#[cfg(target_arch = "wasm32")]
{
true
}
})
}
pub trait WidgetExtension: Send + Sync + 'static {
fn type_names(&self) -> &[&str];
fn config_key(&self) -> &str;
fn init(&mut self, _ctx: &InitCtx<'_>) {}
fn prepare(&mut self, _node: &TreeNode, _caches: &mut ExtensionCaches, _theme: &Theme) {}
fn render<'a>(&self, node: &'a TreeNode, env: &WidgetEnv<'a>) -> Element<'a, Message>;
fn handle_event(
&mut self,
_node_id: &str,
_family: &str,
_data: &Value,
_caches: &mut ExtensionCaches,
) -> EventResult {
EventResult::PassThrough
}
fn handle_command(
&mut self,
_node_id: &str,
_op: &str,
_payload: &Value,
_caches: &mut ExtensionCaches,
) -> Vec<OutgoingEvent> {
vec![]
}
fn cleanup(&mut self, _node_id: &str, _caches: &mut ExtensionCaches) {}
fn new_instance(&self) -> Box<dyn WidgetExtension> {
unimplemented!(
"extension `{}` does not support multiplexed sessions; \
implement new_instance() to enable --max-sessions > 1",
self.config_key()
);
}
}
#[derive(Debug)]
#[must_use = "an EventResult should not be silently discarded"]
pub enum EventResult {
PassThrough,
Consumed(Vec<OutgoingEvent>),
Observed(Vec<OutgoingEvent>),
}
pub struct ExtensionCaches {
inner: HashMap<String, Box<dyn Any + Send + Sync>>,
}
impl std::fmt::Debug for ExtensionCaches {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ExtensionCaches")
.field("entries", &self.inner.len())
.field("keys", &self.inner.keys().collect::<Vec<_>>())
.finish()
}
}
impl ExtensionCaches {
pub fn new() -> Self {
Self {
inner: HashMap::new(),
}
}
fn namespaced_key(namespace: &str, key: &str) -> String {
format!("{namespace}:{key}")
}
pub fn get<T: 'static>(&self, namespace: &str, key: &str) -> Option<&T> {
let full_key = Self::namespaced_key(namespace, key);
let entry = self.inner.get(&full_key)?;
let result = entry.downcast_ref();
if result.is_none() {
log::warn!(
"extension cache type mismatch for `{full_key}`: \
stored type does not match requested type"
);
}
result
}
pub fn get_mut<T: 'static>(&mut self, namespace: &str, key: &str) -> Option<&mut T> {
let full_key = Self::namespaced_key(namespace, key);
let entry = self.inner.get_mut(&full_key)?;
let result = entry.downcast_mut();
if result.is_none() {
log::warn!(
"extension cache type mismatch for `{full_key}`: \
stored type does not match requested type"
);
}
result
}
pub fn get_or_insert<T: Send + Sync + 'static>(
&mut self,
namespace: &str,
key: &str,
default: impl FnOnce() -> T,
) -> &mut T {
let ns_key = Self::namespaced_key(namespace, key);
let needs_replace = self
.inner
.get(&ns_key)
.is_some_and(|v| v.downcast_ref::<T>().is_none());
if needs_replace {
log::warn!(
"extension cache type mismatch for key `{ns_key}`: \
replacing existing entry with new default"
);
self.inner.remove(&ns_key);
}
self.inner
.entry(ns_key)
.or_insert_with(|| Box::new(default()))
.downcast_mut()
.expect("downcast must succeed: entry was just inserted with correct type")
}
pub fn insert<T: Send + Sync + 'static>(&mut self, namespace: &str, key: &str, value: T) {
self.inner
.insert(Self::namespaced_key(namespace, key), Box::new(value));
}
pub fn remove(&mut self, namespace: &str, key: &str) -> bool {
self.inner
.remove(&Self::namespaced_key(namespace, key))
.is_some()
}
pub fn contains(&self, namespace: &str, key: &str) -> bool {
self.inner
.contains_key(&Self::namespaced_key(namespace, key))
}
pub fn remove_namespace(&mut self, namespace: &str) {
let prefix = format!("{namespace}:");
self.inner.retain(|k, _| !k.starts_with(&prefix));
}
pub fn clear(&mut self) {
self.inner.clear();
}
}
impl Default for ExtensionCaches {
fn default() -> Self {
Self::new()
}
}
pub struct WidgetEnv<'a> {
pub caches: &'a ExtensionCaches,
pub ctx: RenderCtx<'a>,
}
impl std::fmt::Debug for WidgetEnv<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WidgetEnv")
.field("caches", self.caches)
.field("ctx", &self.ctx)
.finish()
}
}
impl<'a> WidgetEnv<'a> {
pub fn images(&self) -> &'a ImageRegistry {
self.ctx.images
}
pub fn theme(&self) -> &'a Theme {
self.ctx.theme
}
pub fn default_text_size(&self) -> Option<f32> {
self.ctx.default_text_size
}
pub fn default_font(&self) -> Option<iced::Font> {
self.ctx.default_font
}
pub fn render_child(&self, node: &'a TreeNode) -> Element<'a, Message> {
self.ctx.render_child(node)
}
pub fn window_id(&self) -> &'a str {
self.ctx.window_id
}
pub fn scale_factor(&self) -> f32 {
self.ctx.scale_factor
}
}
#[derive(Debug)]
pub struct InitCtx<'a> {
pub config: &'a Value,
pub theme: &'a Theme,
pub default_text_size: Option<f32>,
pub default_font: Option<iced::Font>,
}
#[derive(Clone, Copy)]
pub struct RenderCtx<'a> {
pub caches: &'a WidgetCaches,
pub images: &'a ImageRegistry,
pub theme: &'a Theme,
pub extensions: &'a ExtensionDispatcher,
pub default_text_size: Option<f32>,
pub default_font: Option<iced::Font>,
pub window_id: &'a str,
pub scale_factor: f32,
}
impl std::fmt::Debug for RenderCtx<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RenderCtx")
.field("window_id", &self.window_id)
.field("scale_factor", &self.scale_factor)
.field("default_text_size", &self.default_text_size)
.field("default_font", &self.default_font)
.finish_non_exhaustive()
}
}
impl<'a> RenderCtx<'a> {
pub fn render_child(&self, node: &'a TreeNode) -> Element<'a, Message> {
crate::widgets::render(node, *self)
}
pub fn with_theme(&self, theme: &'a Theme) -> Self {
RenderCtx { theme, ..*self }
}
pub fn render_children(&self, node: &'a TreeNode) -> Vec<Element<'a, Message>> {
node.children.iter().map(|c| self.render_child(c)).collect()
}
}
const RENDER_PANIC_THRESHOLD: u32 = 3;
pub struct ExtensionDispatcher {
extensions: Vec<Box<dyn WidgetExtension>>,
type_name_index: HashMap<String, usize>,
node_extension_map: HashMap<String, usize>,
poisoned: Vec<bool>,
render_panic_counts: Vec<AtomicU32>,
}
impl std::fmt::Debug for ExtensionDispatcher {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let type_names: Vec<_> = self.type_name_index.keys().collect();
f.debug_struct("ExtensionDispatcher")
.field("extensions", &self.extensions.len())
.field("type_names", &type_names)
.field("poisoned", &self.poisoned)
.finish()
}
}
impl ExtensionDispatcher {
pub fn new(extensions: Vec<Box<dyn WidgetExtension>>) -> Self {
let n = extensions.len();
for ext in &extensions {
if ext.config_key().is_empty() {
panic!(
"extension registered with empty config_key() \
(type_names: {:?})",
ext.type_names()
);
}
if ext.config_key().contains(':') {
panic!(
"extension config_key `{}` contains ':' (reserved as \
cache namespace separator); type_names: {:?}",
ext.config_key(),
ext.type_names()
);
}
if ext.type_names().is_empty() {
log::warn!(
"extension `{}` registered with empty type_names(); \
it will never match any node type",
ext.config_key()
);
}
}
let mut seen_config_keys: HashMap<&str, usize> = HashMap::new();
for (idx, ext) in extensions.iter().enumerate() {
let key = ext.config_key();
if let Some(prev_idx) = seen_config_keys.insert(key, idx) {
panic!(
"duplicate extension config_key `{key}`: \
extension at index {prev_idx} (type_names: {:?}) and \
extension at index {idx} (type_names: {:?}) both use it",
extensions[prev_idx].type_names(),
ext.type_names(),
);
}
}
let mut type_name_index = HashMap::new();
for (idx, ext) in extensions.iter().enumerate() {
for &name in ext.type_names() {
if let Some(prev_idx) = type_name_index.insert(name.to_string(), idx) {
panic!(
"duplicate extension type name `{name}`: \
extension `{}` (index {prev_idx}) and \
extension `{}` (index {idx}) both claim it",
extensions[prev_idx].config_key(),
ext.config_key(),
);
}
}
}
let render_panic_counts = (0..n).map(|_| AtomicU32::new(0)).collect();
Self {
extensions,
type_name_index,
node_extension_map: HashMap::new(),
poisoned: vec![false; n],
render_panic_counts,
}
}
pub fn clone_for_session(&self) -> Result<Self, String> {
let mut extensions: Vec<Box<dyn WidgetExtension>> =
Vec::with_capacity(self.extensions.len());
for ext in &self.extensions {
let key = ext.config_key().to_string();
if catch_unwind_enabled() {
match catch_unwind(AssertUnwindSafe(|| ext.new_instance())) {
Ok(instance) => extensions.push(instance),
Err(payload) => {
let msg = panic_message(&payload);
return Err(format!(
"extension `{key}` panicked in new_instance(): {msg}"
));
}
}
} else {
extensions.push(ext.new_instance());
}
}
Ok(Self::new(extensions))
}
pub fn handles_type(&self, type_name: &str) -> bool {
self.type_name_index.contains_key(type_name)
}
const MAX_WALK_DEPTH: usize = crate::widgets::MAX_TREE_DEPTH;
pub fn prepare_all(&mut self, root: &TreeNode, caches: &mut ExtensionCaches, theme: &Theme) {
let mut new_map = HashMap::new();
self.walk_prepare(root, caches, theme, &mut new_map, 0);
for (old_id, ext_idx) in &self.node_extension_map {
if !new_map.contains_key(old_id) {
let ns = self.extensions[*ext_idx].config_key().to_string();
if self.poisoned[*ext_idx] {
caches.remove(&ns, old_id);
log::warn!(
"skipping cleanup for poisoned extension `{ns}`; \
cache entry removed for node `{old_id}`",
);
} else if catch_unwind_enabled() {
let result = catch_unwind(AssertUnwindSafe(|| {
self.extensions[*ext_idx].cleanup(old_id, caches);
}));
if let Err(panic) = result {
let msg = panic_message(&panic);
log::error!("extension `{ns}` panicked in cleanup: {msg}",);
self.poisoned[*ext_idx] = true;
}
caches.remove(&ns, old_id);
} else {
self.extensions[*ext_idx].cleanup(old_id, caches);
caches.remove(&ns, old_id);
}
}
}
self.node_extension_map = new_map;
for idx in 0..self.extensions.len() {
let count = self.render_panic_counts[idx].load(Ordering::Relaxed);
if count >= RENDER_PANIC_THRESHOLD && !self.poisoned[idx] {
log::error!(
"extension `{}` hit {} consecutive render panics, poisoning",
self.extensions[idx].config_key(),
count,
);
self.poisoned[idx] = true;
}
if !self.poisoned[idx] {
self.render_panic_counts[idx].store(0, Ordering::Relaxed);
}
}
}
fn walk_prepare(
&mut self,
node: &TreeNode,
caches: &mut ExtensionCaches,
theme: &Theme,
map: &mut HashMap<String, usize>,
depth: usize,
) {
if depth > Self::MAX_WALK_DEPTH {
log::warn!(
"[id={}] walk_prepare depth exceeds {}, skipping subtree",
node.id,
Self::MAX_WALK_DEPTH
);
return;
}
if let Some(&idx) = self.type_name_index.get(node.type_name.as_str()) {
if !self.poisoned[idx] {
if catch_unwind_enabled() {
let result = catch_unwind(AssertUnwindSafe(|| {
self.extensions[idx].prepare(node, caches, theme);
}));
if let Err(panic) = result {
let msg = panic_message(&panic);
log::error!(
"extension `{}` panicked in prepare: {msg}",
self.extensions[idx].config_key()
);
self.poisoned[idx] = true;
}
} else {
self.extensions[idx].prepare(node, caches, theme);
}
}
map.insert(node.id.clone(), idx);
}
for child in &node.children {
self.walk_prepare(child, caches, theme, map, depth + 1);
}
}
pub fn handle_event(
&mut self,
id: &str,
family: &str,
data: &Value,
caches: &mut ExtensionCaches,
) -> EventResult {
let ext_idx = match self.node_extension_map.get(id) {
Some(&idx) => idx,
None => return EventResult::PassThrough,
};
if self.poisoned[ext_idx] {
log::error!(
"extension `{}` is poisoned, dropping event `{family}` for node `{id}`",
self.extensions[ext_idx].config_key()
);
return EventResult::PassThrough;
}
if catch_unwind_enabled() {
match catch_unwind(AssertUnwindSafe(|| {
self.extensions[ext_idx].handle_event(id, family, data, caches)
})) {
Ok(result) => result,
Err(panic) => {
let msg = panic_message(&panic);
log::error!(
"extension `{}` panicked in handle_event \
(node_id={id}, family={family}): {msg}",
self.extensions[ext_idx].config_key()
);
self.poisoned[ext_idx] = true;
EventResult::PassThrough
}
}
} else {
self.extensions[ext_idx].handle_event(id, family, data, caches)
}
}
pub fn handle_command(
&mut self,
node_id: &str,
op: &str,
payload: &Value,
caches: &mut ExtensionCaches,
) -> Vec<OutgoingEvent> {
let ext_idx = match self.node_extension_map.get(node_id) {
Some(&idx) => idx,
None => {
log::warn!("extension command for unknown node `{node_id}`, ignoring");
return vec![OutgoingEvent::generic(
"extension_error".to_string(),
node_id.to_string(),
Some(serde_json::json!({
"error": format!("no extension handles node `{node_id}`"),
"op": op,
})),
)];
}
};
if self.poisoned[ext_idx] {
return vec![OutgoingEvent::generic(
"extension_error".to_string(),
node_id.to_string(),
Some(serde_json::json!({
"error": "extension is disabled due to previous panics",
"op": op,
})),
)];
}
if catch_unwind_enabled() {
match catch_unwind(AssertUnwindSafe(|| {
self.extensions[ext_idx].handle_command(node_id, op, payload, caches)
})) {
Ok(events) => events,
Err(panic) => {
let msg = panic_message(&panic);
log::error!(
"extension `{}` panicked in handle_command: {msg}",
self.extensions[ext_idx].config_key()
);
self.poisoned[ext_idx] = true;
let error_data = serde_json::json!({
"error": msg,
"op": op,
});
vec![OutgoingEvent::generic(
"extension_error",
node_id.to_string(),
Some(error_data),
)]
}
}
} else {
self.extensions[ext_idx].handle_command(node_id, op, payload, caches)
}
}
pub fn init_all(
&mut self,
config: &Value,
theme: &Theme,
default_text_size: Option<f32>,
default_font: Option<iced::Font>,
) {
for (idx, ext) in self.extensions.iter_mut().enumerate() {
if self.poisoned[idx] {
continue;
}
let key = ext.config_key().to_string();
let ext_config = config.get(&key).unwrap_or(&Value::Null);
let ctx = InitCtx {
config: ext_config,
theme,
default_text_size,
default_font,
};
if catch_unwind_enabled() {
let result = catch_unwind(AssertUnwindSafe(|| {
ext.init(&ctx);
}));
if let Err(panic) = result {
let msg = panic_message(&panic);
log::error!("extension `{key}` panicked in init: {msg}");
self.poisoned[idx] = true;
}
} else {
ext.init(&ctx);
}
}
}
pub fn render<'a>(
&'a self,
node: &'a TreeNode,
env: &WidgetEnv<'a>,
) -> Option<Element<'a, Message>> {
let &idx = self.type_name_index.get(node.type_name.as_str())?;
if self.poisoned[idx] {
return Some(render_poisoned_placeholder(node));
}
let element = self.extensions[idx].render(node, env);
self.render_panic_counts[idx].store(0, Ordering::Relaxed);
Some(element)
}
pub fn record_render_panic(&self, type_name: &str) -> bool {
if let Some(&idx) = self.type_name_index.get(type_name) {
let prev = self.render_panic_counts[idx].fetch_add(1, Ordering::Relaxed);
prev + 1 >= RENDER_PANIC_THRESHOLD
} else {
false
}
}
pub fn clear_poisoned(&mut self) {
self.poisoned.fill(false);
for counter in &self.render_panic_counts {
counter.store(0, Ordering::Relaxed);
}
}
pub fn cleanup_all(&mut self, caches: &mut ExtensionCaches) {
for (node_id, &ext_idx) in &self.node_extension_map {
if self.poisoned[ext_idx] {
continue;
}
if catch_unwind_enabled() {
let result = catch_unwind(AssertUnwindSafe(|| {
self.extensions[ext_idx].cleanup(node_id, caches);
}));
if let Err(panic) = result {
let msg = panic_message(&panic);
log::error!(
"extension `{}` panicked in cleanup: {msg}",
self.extensions[ext_idx].config_key()
);
self.poisoned[ext_idx] = true;
}
} else {
self.extensions[ext_idx].cleanup(node_id, caches);
}
}
}
pub fn reset(&mut self, caches: &mut ExtensionCaches) {
self.cleanup_all(caches);
self.node_extension_map.clear();
caches.clear();
self.clear_poisoned();
}
pub fn is_empty(&self) -> bool {
self.extensions.is_empty()
}
#[cfg(test)]
pub fn is_poisoned(&self, idx: usize) -> bool {
self.poisoned.get(idx).copied().unwrap_or(false)
}
pub fn len(&self) -> usize {
self.extensions.len()
}
pub fn config_keys(&self) -> Vec<&str> {
self.extensions.iter().map(|e| e.config_key()).collect()
}
}
impl Default for ExtensionDispatcher {
fn default() -> Self {
Self::new(vec![])
}
}
#[derive(Debug, Clone)]
pub struct GenerationCounter {
value: u64,
}
impl GenerationCounter {
pub fn new() -> Self {
Self { value: 0 }
}
pub fn get(&self) -> u64 {
self.value
}
pub fn bump(&mut self) {
self.value = self.value.wrapping_add(1);
}
}
impl Default for GenerationCounter {
fn default() -> Self {
Self::new()
}
}
fn render_poisoned_placeholder<'a>(node: &TreeNode) -> Element<'a, Message> {
use iced::Color;
use iced::widget::text;
text(format!(
"Extension error: type `{}`, node `{}` (see logs)",
node.type_name, node.id
))
.color(Color::from_rgb(1.0, 0.0, 0.0))
.into()
}
fn panic_message(panic: &Box<dyn Any + Send>) -> String {
if let Some(s) = panic.downcast_ref::<&str>() {
s.to_string()
} else if let Some(s) = panic.downcast_ref::<String>() {
s.clone()
} else {
"unknown panic".to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
struct TestExtension {
type_names: Vec<&'static str>,
config_key: &'static str,
init_called: bool,
}
impl TestExtension {
fn new(type_names: Vec<&'static str>, config_key: &'static str) -> Self {
Self {
type_names,
config_key,
init_called: false,
}
}
}
impl WidgetExtension for TestExtension {
fn type_names(&self) -> &[&str] {
&self.type_names
}
fn config_key(&self) -> &str {
self.config_key
}
fn init(&mut self, _ctx: &InitCtx<'_>) {
self.init_called = true;
}
fn render<'a>(&self, node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
use iced::widget::text;
text(format!("test:{}", node.id)).into()
}
}
struct EmptyTypesExtension;
impl WidgetExtension for EmptyTypesExtension {
fn type_names(&self) -> &[&str] {
&[]
}
fn config_key(&self) -> &str {
"empty_types"
}
fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
use iced::widget::text;
text("empty").into()
}
}
use crate::testing::node as make_node;
#[test]
fn registration_builds_type_name_index() {
let ext = TestExtension::new(vec!["sparkline", "heatmap"], "charts");
let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
assert!(dispatcher.handles_type("sparkline"));
assert!(dispatcher.handles_type("heatmap"));
assert!(!dispatcher.handles_type("unknown"));
}
#[test]
fn registration_with_multiple_extensions() {
let ext_a = TestExtension::new(vec!["sparkline"], "charts");
let ext_b = TestExtension::new(vec!["gauge"], "instruments");
let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext_a), Box::new(ext_b)]);
assert!(dispatcher.handles_type("sparkline"));
assert!(dispatcher.handles_type("gauge"));
assert_eq!(dispatcher.len(), 2);
}
#[test]
fn empty_dispatcher_handles_nothing() {
let dispatcher = ExtensionDispatcher::default();
assert!(dispatcher.is_empty());
assert!(!dispatcher.handles_type("anything"));
}
#[test]
#[should_panic(expected = "duplicate extension type name `sparkline`")]
fn duplicate_type_name_panics() {
let ext_a = TestExtension::new(vec!["sparkline"], "charts_a");
let ext_b = TestExtension::new(vec!["sparkline"], "charts_b");
ExtensionDispatcher::new(vec![Box::new(ext_a), Box::new(ext_b)]);
}
#[test]
#[should_panic(expected = "both claim it")]
fn duplicate_type_name_error_identifies_conflicting_extensions() {
let ext_a = TestExtension::new(vec!["widget_x"], "ext_alpha");
let ext_b = TestExtension::new(vec!["widget_x"], "ext_beta");
ExtensionDispatcher::new(vec![Box::new(ext_a), Box::new(ext_b)]);
}
#[test]
#[should_panic(expected = "empty config_key()")]
fn empty_config_key_panics() {
let ext = TestExtension::new(vec!["widget"], "");
ExtensionDispatcher::new(vec![Box::new(ext)]);
}
#[test]
#[should_panic(expected = "contains ':'")]
fn config_key_with_colon_panics() {
let ext = TestExtension::new(vec!["widget"], "bad:key");
ExtensionDispatcher::new(vec![Box::new(ext)]);
}
#[test]
#[should_panic(expected = "duplicate extension config_key `charts`")]
fn duplicate_config_key_panics() {
let ext_a = TestExtension::new(vec!["sparkline"], "charts");
let ext_b = TestExtension::new(vec!["heatmap"], "charts");
ExtensionDispatcher::new(vec![Box::new(ext_a), Box::new(ext_b)]);
}
#[test]
fn empty_type_names_does_not_panic() {
let ext = EmptyTypesExtension;
let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
assert_eq!(dispatcher.len(), 1);
assert!(!dispatcher.handles_type("anything"));
}
#[test]
fn cache_insert_and_get() {
let mut caches = ExtensionCaches::new();
caches.insert("charts", "node1", 42u32);
assert_eq!(caches.get::<u32>("charts", "node1"), Some(&42));
assert_eq!(caches.get::<u32>("charts", "node2"), None);
}
#[test]
fn cache_get_mut() {
let mut caches = ExtensionCaches::new();
caches.insert("ns", "key", vec![1, 2, 3]);
if let Some(v) = caches.get_mut::<Vec<i32>>("ns", "key") {
v.push(4);
}
assert_eq!(caches.get::<Vec<i32>>("ns", "key"), Some(&vec![1, 2, 3, 4]));
}
#[test]
fn cache_get_or_insert_creates_default() {
let mut caches = ExtensionCaches::new();
let val = caches.get_or_insert::<String>("ns", "key", || "hello".to_string());
assert_eq!(val, "hello");
let val = caches.get_or_insert::<String>("ns", "key", || "world".to_string());
assert_eq!(val, "hello");
}
#[test]
fn cache_get_or_insert_type_mismatch_replaces_with_default() {
let mut caches = ExtensionCaches::new();
caches.insert("ns", "key", 42u32);
let val = caches.get_or_insert::<String>("ns", "key", || "replaced".to_string());
assert_eq!(val, "replaced");
}
#[test]
fn cache_wrong_type_returns_none() {
let mut caches = ExtensionCaches::new();
caches.insert("ns", "key", 42u32);
assert_eq!(caches.get::<String>("ns", "key"), None);
}
#[test]
fn cache_remove_and_contains() {
let mut caches = ExtensionCaches::new();
caches.insert("ns", "key", 1u8);
assert!(caches.contains("ns", "key"));
assert!(caches.remove("ns", "key"));
assert!(!caches.contains("ns", "key"));
assert!(!caches.remove("ns", "key"));
}
#[test]
fn cache_clear_removes_everything() {
let mut caches = ExtensionCaches::new();
caches.insert("a", "k1", 1u32);
caches.insert("b", "k2", 2u32);
caches.clear();
assert!(!caches.contains("a", "k1"));
assert!(!caches.contains("b", "k2"));
}
#[test]
fn cache_namespace_isolation() {
let mut caches = ExtensionCaches::new();
caches.insert("charts", "data", vec![1.0f64, 2.0, 3.0]);
caches.insert("gauges", "data", 42u32);
assert_eq!(
caches.get::<Vec<f64>>("charts", "data"),
Some(&vec![1.0, 2.0, 3.0])
);
assert_eq!(caches.get::<u32>("gauges", "data"), Some(&42));
}
#[test]
fn cache_remove_namespace() {
let mut caches = ExtensionCaches::new();
caches.insert("charts", "a", 1u32);
caches.insert("charts", "b", 2u32);
caches.insert("gauges", "a", 3u32);
caches.remove_namespace("charts");
assert!(!caches.contains("charts", "a"));
assert!(!caches.contains("charts", "b"));
assert!(caches.contains("gauges", "a"));
}
#[test]
fn poison_flag_set_and_clear() {
let ext = TestExtension::new(vec!["sparkline"], "charts");
let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
assert!(!dispatcher.is_poisoned(0));
for _ in 0..RENDER_PANIC_THRESHOLD {
dispatcher.record_render_panic("sparkline");
}
let root = make_node("root", "column");
let mut caches = ExtensionCaches::new();
dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
assert!(dispatcher.is_poisoned(0));
dispatcher.clear_poisoned();
assert!(!dispatcher.is_poisoned(0));
}
#[test]
fn record_render_panic_increments_counter() {
let ext = TestExtension::new(vec!["sparkline"], "charts");
let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
assert!(!dispatcher.record_render_panic("sparkline"));
assert!(!dispatcher.record_render_panic("sparkline"));
assert!(dispatcher.record_render_panic("sparkline"));
}
#[test]
fn record_render_panic_unknown_type_returns_false() {
let dispatcher = ExtensionDispatcher::default();
assert!(!dispatcher.record_render_panic("nonexistent"));
}
#[test]
fn event_result_pass_through() {
let result = EventResult::PassThrough;
assert!(matches!(result, EventResult::PassThrough));
}
#[test]
fn event_result_consumed_with_events() {
let events = vec![OutgoingEvent::generic("test", "n1".to_string(), None)];
let result = EventResult::Consumed(events);
match result {
EventResult::Consumed(e) => assert_eq!(e.len(), 1),
_ => panic!("expected Consumed"),
}
}
#[test]
fn event_result_observed_with_events() {
let events = vec![OutgoingEvent::generic("test", "n1".to_string(), None)];
let result = EventResult::Observed(events);
match result {
EventResult::Observed(e) => assert_eq!(e.len(), 1),
_ => panic!("expected Observed"),
}
}
#[test]
fn generation_counter_starts_at_zero() {
let counter = GenerationCounter::new();
assert_eq!(counter.get(), 0);
}
#[test]
fn generation_counter_bumps() {
let mut counter = GenerationCounter::new();
counter.bump();
assert_eq!(counter.get(), 1);
counter.bump();
assert_eq!(counter.get(), 2);
}
#[test]
fn generation_counter_default() {
let counter = GenerationCounter::default();
assert_eq!(counter.get(), 0);
}
#[test]
fn init_all_routes_config_by_key() {
let ext = TestExtension::new(vec!["sparkline"], "charts");
let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
let config = serde_json::json!({"charts": {"color": "red"}});
dispatcher.init_all(&config, &Theme::Dark, None, None);
assert!(!dispatcher.is_poisoned(0));
}
#[test]
fn panic_message_extracts_str() {
let p: Box<dyn Any + Send> = Box::new("boom");
assert_eq!(panic_message(&p), "boom");
}
#[test]
fn panic_message_extracts_string() {
let p: Box<dyn Any + Send> = Box::new("kaboom".to_string());
assert_eq!(panic_message(&p), "kaboom");
}
#[test]
fn panic_message_unknown_type() {
let p: Box<dyn Any + Send> = Box::new(42u32);
assert_eq!(panic_message(&p), "unknown panic");
}
struct PanickingCommandExtension;
impl WidgetExtension for PanickingCommandExtension {
fn type_names(&self) -> &[&str] {
&["panicker"]
}
fn config_key(&self) -> &str {
"panicker"
}
fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
use iced::widget::text;
text("panicker").into()
}
fn handle_command(
&mut self,
_node_id: &str,
_op: &str,
_payload: &Value,
_caches: &mut ExtensionCaches,
) -> Vec<OutgoingEvent> {
panic!("command went boom");
}
}
#[test]
fn handle_command_panic_emits_error_event() {
let ext = PanickingCommandExtension;
let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
let mut caches = ExtensionCaches::new();
let mut root = make_node("root", "column");
root.children.push(make_node("p1", "panicker"));
dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
let events = dispatcher.handle_command("p1", "do_thing", &Value::Null, &mut caches);
assert_eq!(events.len(), 1);
let event = &events[0];
assert_eq!(event.family, "extension_error");
assert_eq!(event.id, "p1");
let data = event.data.as_ref().expect("should have data");
assert_eq!(
data.get("error").and_then(|v| v.as_str()),
Some("command went boom")
);
assert_eq!(data.get("op").and_then(|v| v.as_str()), Some("do_thing"));
assert!(dispatcher.is_poisoned(0));
}
#[test]
fn handle_command_poisoned_returns_error_event() {
let ext = PanickingCommandExtension;
let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
let mut caches = ExtensionCaches::new();
let mut root = make_node("root", "column");
root.children.push(make_node("p1", "panicker"));
dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
for _ in 0..RENDER_PANIC_THRESHOLD {
dispatcher.record_render_panic("panicker");
}
dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
assert!(dispatcher.is_poisoned(0));
let events = dispatcher.handle_command("p1", "do_thing", &Value::Null, &mut caches);
assert_eq!(events.len(), 1);
let event = &events[0];
assert_eq!(event.family, "extension_error");
assert_eq!(event.id, "p1");
let data = event.data.as_ref().expect("should have data");
assert_eq!(
data.get("error").and_then(|v| v.as_str()),
Some("extension is disabled due to previous panics")
);
assert_eq!(data.get("op").and_then(|v| v.as_str()), Some("do_thing"));
}
struct CleanupTracker {
cleaned_ids: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
}
impl CleanupTracker {
fn new(tracker: std::sync::Arc<std::sync::Mutex<Vec<String>>>) -> Self {
Self {
cleaned_ids: tracker,
}
}
}
impl WidgetExtension for CleanupTracker {
fn type_names(&self) -> &[&str] {
&["tracked"]
}
fn config_key(&self) -> &str {
"tracker"
}
fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
use iced::widget::text;
text("tracked").into()
}
fn cleanup(&mut self, node_id: &str, _caches: &mut ExtensionCaches) {
self.cleaned_ids.lock().unwrap().push(node_id.to_string());
}
}
#[test]
fn cleanup_all_calls_cleanup_for_tracked_nodes() {
let tracker = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let ext = CleanupTracker::new(tracker.clone());
let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
let mut caches = ExtensionCaches::new();
let mut root = make_node("root", "column");
root.children.push(make_node("t1", "tracked"));
root.children.push(make_node("t2", "tracked"));
dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
dispatcher.cleanup_all(&mut caches);
let cleaned = tracker.lock().unwrap();
assert!(cleaned.contains(&"t1".to_string()));
assert!(cleaned.contains(&"t2".to_string()));
assert_eq!(cleaned.len(), 2);
}
#[test]
fn cleanup_all_skips_poisoned_extensions() {
let tracker = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let ext = CleanupTracker::new(tracker.clone());
let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
let mut caches = ExtensionCaches::new();
let mut root = make_node("root", "column");
root.children.push(make_node("t1", "tracked"));
dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
for _ in 0..RENDER_PANIC_THRESHOLD {
dispatcher.record_render_panic("tracked");
}
dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
assert!(dispatcher.is_poisoned(0));
dispatcher.cleanup_all(&mut caches);
assert!(tracker.lock().unwrap().is_empty());
}
#[test]
fn reset_clears_node_map_and_caches() {
let ext = TestExtension::new(vec!["sparkline"], "charts");
let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
let mut caches = ExtensionCaches::new();
let mut root = make_node("root", "column");
root.children.push(make_node("s1", "sparkline"));
dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
caches.insert("charts", "s1", 42u32);
assert!(caches.contains("charts", "s1"));
dispatcher.reset(&mut caches);
assert!(!caches.contains("charts", "s1"));
assert!(!dispatcher.is_poisoned(0));
let result = dispatcher.handle_event("s1", "click", &Value::Null, &mut caches);
assert!(matches!(result, EventResult::PassThrough));
}
#[test]
fn reset_clears_poisoned_state() {
let ext = TestExtension::new(vec!["sparkline"], "charts");
let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
let mut caches = ExtensionCaches::new();
for _ in 0..RENDER_PANIC_THRESHOLD {
dispatcher.record_render_panic("sparkline");
}
let root = make_node("root", "column");
dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
assert!(dispatcher.is_poisoned(0));
dispatcher.reset(&mut caches);
assert!(!dispatcher.is_poisoned(0));
}
struct PanickingRenderExtension;
impl WidgetExtension for PanickingRenderExtension {
fn type_names(&self) -> &[&str] {
&["panicky_render"]
}
fn config_key(&self) -> &str {
"panicky_render"
}
fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
panic!("render goes boom");
}
}
#[test]
fn poison_lifecycle_render_panics_then_clear() {
let ext: Box<dyn WidgetExtension> = Box::new(PanickingRenderExtension);
let mut dispatcher = ExtensionDispatcher::new(vec![ext]);
let mut caches = ExtensionCaches::new();
let images = crate::image_registry::ImageRegistry::new();
let theme = Theme::Dark;
let mut root = make_node("root", "column");
root.children.push(make_node("pr1", "panicky_render"));
dispatcher.prepare_all(&root, &mut caches, &theme);
assert!(!dispatcher.is_poisoned(0));
for i in 0..RENDER_PANIC_THRESHOLD {
let at_threshold = dispatcher.record_render_panic("panicky_render");
if i < RENDER_PANIC_THRESHOLD - 1 {
assert!(!at_threshold, "should not be at threshold yet (i={i})");
} else {
assert!(at_threshold, "should be at threshold now");
}
}
dispatcher.prepare_all(&root, &mut caches, &theme);
assert!(
dispatcher.is_poisoned(0),
"extension should be poisoned after threshold + prepare_all"
);
let node = make_node("pr1", "panicky_render");
{
let widget_caches = crate::widgets::WidgetCaches::new();
let ctx = RenderCtx {
caches: &widget_caches,
images: &images,
theme: &theme,
extensions: &dispatcher,
default_text_size: None,
default_font: None,
window_id: "",
scale_factor: 1.0,
};
let env = WidgetEnv {
caches: &caches,
ctx,
};
let result = dispatcher.render(&node, &env);
assert!(
result.is_some(),
"poisoned extension should still return Some (placeholder)"
);
}
dispatcher.clear_poisoned();
assert!(
!dispatcher.is_poisoned(0),
"poison should be cleared after clear_poisoned"
);
let widget_caches2 = crate::widgets::WidgetCaches::new();
let ctx2 = RenderCtx {
caches: &widget_caches2,
images: &images,
theme: &theme,
extensions: &dispatcher,
default_text_size: None,
default_font: None,
window_id: "",
scale_factor: 1.0,
};
let env2 = WidgetEnv {
caches: &caches,
ctx: ctx2,
};
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
dispatcher.render(&node, &env2)
}));
assert!(
result.is_err(),
"after clearing poison, render should call the extension again (which panics)"
);
}
struct CloneableExtension {
label: &'static str,
}
impl CloneableExtension {
fn new(label: &'static str) -> Self {
Self { label }
}
}
impl WidgetExtension for CloneableExtension {
fn type_names(&self) -> &[&str] {
&["cloneable_widget"]
}
fn config_key(&self) -> &str {
"cloneable"
}
fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
use iced::widget::text;
text(self.label).into()
}
fn new_instance(&self) -> Box<dyn WidgetExtension> {
Box::new(CloneableExtension::new(self.label))
}
}
#[test]
fn new_instance_default_panics() {
let ext = TestExtension::new(vec!["sparkline"], "charts");
let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
ext.new_instance();
}));
assert!(result.is_err(), "default new_instance() should panic");
}
#[test]
fn new_instance_custom_returns_fresh_instance() {
let ext = CloneableExtension::new("original");
let fresh = ext.new_instance();
assert_eq!(fresh.type_names(), &["cloneable_widget"]);
assert_eq!(fresh.config_key(), "cloneable");
}
#[test]
fn clone_for_session_uses_new_instance() {
let ext = CloneableExtension::new("session");
let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
let cloned = dispatcher
.clone_for_session()
.expect("clone should succeed");
assert!(cloned.handles_type("cloneable_widget"));
assert_eq!(cloned.len(), 1);
}
#[test]
fn clone_for_session_returns_err_on_panic() {
let ext = TestExtension::new(vec!["sparkline"], "charts");
let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
let result = dispatcher.clone_for_session();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(
err.contains("charts"),
"error should name the extension: {err}"
);
}
}