1use std::any::Any;
16use std::collections::HashMap;
17use std::panic::{AssertUnwindSafe, catch_unwind};
18use std::sync::atomic::{AtomicU32, Ordering};
19
20use iced::{Element, Theme};
21use serde_json::Value;
22
23use crate::image_registry::ImageRegistry;
24use crate::message::Message;
25use crate::protocol::{OutgoingEvent, TreeNode};
26use crate::widgets::WidgetCaches;
27
28pub(crate) fn catch_unwind_enabled() -> bool {
33 static ENABLED: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
34 *ENABLED.get_or_init(|| std::env::var("TODDY_NO_CATCH_UNWIND").is_err())
35}
36
37pub trait WidgetExtension: Send + Sync + 'static {
159 fn type_names(&self) -> &[&str];
161
162 fn config_key(&self) -> &str;
165
166 fn init(&mut self, _config: &Value) {}
169
170 fn prepare(&mut self, _node: &TreeNode, _caches: &mut ExtensionCaches, _theme: &Theme) {}
173
174 fn render<'a>(&self, node: &'a TreeNode, env: &WidgetEnv<'a>) -> Element<'a, Message>;
176
177 fn handle_event(
180 &mut self,
181 _node_id: &str,
182 _family: &str,
183 _data: &Value,
184 _caches: &mut ExtensionCaches,
185 ) -> EventResult {
186 EventResult::PassThrough
187 }
188
189 fn handle_command(
201 &mut self,
202 _node_id: &str,
203 _op: &str,
204 _payload: &Value,
205 _caches: &mut ExtensionCaches,
206 ) -> Vec<OutgoingEvent> {
207 vec![]
208 }
209
210 fn cleanup(&mut self, _node_id: &str, _caches: &mut ExtensionCaches) {}
212
213 fn new_instance(&self) -> Box<dyn WidgetExtension> {
220 unimplemented!(
221 "extension `{}` does not support multiplexed sessions; \
222 implement new_instance() to enable --max-sessions > 1",
223 self.config_key()
224 );
225 }
226}
227
228#[derive(Debug)]
237#[must_use = "an EventResult should not be silently discarded"]
238pub enum EventResult {
239 PassThrough,
241 Consumed(Vec<OutgoingEvent>),
247 Observed(Vec<OutgoingEvent>),
250}
251
252pub struct ExtensionCaches {
263 inner: HashMap<String, Box<dyn Any + Send + Sync>>,
264}
265
266impl ExtensionCaches {
267 pub fn new() -> Self {
268 Self {
269 inner: HashMap::new(),
270 }
271 }
272
273 fn namespaced_key(namespace: &str, key: &str) -> String {
275 format!("{namespace}:{key}")
276 }
277
278 pub fn get<T: 'static>(&self, namespace: &str, key: &str) -> Option<&T> {
279 self.inner
280 .get(&Self::namespaced_key(namespace, key))?
281 .downcast_ref()
282 }
283
284 pub fn get_mut<T: 'static>(&mut self, namespace: &str, key: &str) -> Option<&mut T> {
285 self.inner
286 .get_mut(&Self::namespaced_key(namespace, key))?
287 .downcast_mut()
288 }
289
290 pub fn get_or_insert<T: Send + Sync + 'static>(
291 &mut self,
292 namespace: &str,
293 key: &str,
294 default: impl FnOnce() -> T,
295 ) -> &mut T {
296 let ns_key = Self::namespaced_key(namespace, key);
297
298 let needs_replace = self
302 .inner
303 .get(&ns_key)
304 .is_some_and(|v| v.downcast_ref::<T>().is_none());
305
306 if needs_replace {
307 log::warn!(
308 "extension cache type mismatch for key `{ns_key}`: \
309 replacing existing entry with new default"
310 );
311 self.inner.remove(&ns_key);
312 }
313
314 self.inner
315 .entry(ns_key)
316 .or_insert_with(|| Box::new(default()))
317 .downcast_mut()
318 .expect("downcast must succeed: entry was just inserted with correct type")
319 }
320
321 pub fn insert<T: Send + Sync + 'static>(&mut self, namespace: &str, key: &str, value: T) {
322 self.inner
323 .insert(Self::namespaced_key(namespace, key), Box::new(value));
324 }
325
326 pub fn remove(&mut self, namespace: &str, key: &str) -> bool {
327 self.inner
328 .remove(&Self::namespaced_key(namespace, key))
329 .is_some()
330 }
331
332 pub fn contains(&self, namespace: &str, key: &str) -> bool {
333 self.inner
334 .contains_key(&Self::namespaced_key(namespace, key))
335 }
336
337 pub fn remove_namespace(&mut self, namespace: &str) {
339 let prefix = format!("{namespace}:");
340 self.inner.retain(|k, _| !k.starts_with(&prefix));
341 }
342
343 pub fn clear(&mut self) {
344 self.inner.clear();
345 }
346}
347
348impl Default for ExtensionCaches {
349 fn default() -> Self {
350 Self::new()
351 }
352}
353
354pub struct WidgetEnv<'a> {
373 pub caches: &'a ExtensionCaches,
374 pub ctx: RenderCtx<'a>,
375}
376
377impl<'a> WidgetEnv<'a> {
378 pub fn images(&self) -> &'a ImageRegistry {
379 self.ctx.images
380 }
381 pub fn theme(&self) -> &'a Theme {
382 self.ctx.theme
383 }
384 pub fn default_text_size(&self) -> Option<f32> {
385 self.ctx.default_text_size
386 }
387 pub fn default_font(&self) -> Option<iced::Font> {
388 self.ctx.default_font
389 }
390 pub fn render_child(&self, node: &'a TreeNode) -> Element<'a, Message> {
391 self.ctx.render_child(node)
392 }
393}
394
395#[derive(Clone, Copy)]
397pub struct RenderCtx<'a> {
398 pub caches: &'a WidgetCaches,
399 pub images: &'a ImageRegistry,
400 pub theme: &'a Theme,
401 pub extensions: &'a ExtensionDispatcher,
402 pub default_text_size: Option<f32>,
403 pub default_font: Option<iced::Font>,
404}
405
406impl<'a> RenderCtx<'a> {
407 pub fn render_child(&self, node: &'a TreeNode) -> Element<'a, Message> {
409 crate::widgets::render(node, *self)
410 }
411
412 pub fn with_theme(&self, theme: &'a Theme) -> Self {
414 RenderCtx { theme, ..*self }
415 }
416
417 pub fn render_children(&self, node: &'a TreeNode) -> Vec<Element<'a, Message>> {
419 node.children.iter().map(|c| self.render_child(c)).collect()
420 }
421}
422
423const RENDER_PANIC_THRESHOLD: u32 = 3;
429
430pub struct ExtensionDispatcher {
437 extensions: Vec<Box<dyn WidgetExtension>>,
438 type_name_index: HashMap<String, usize>,
439 node_extension_map: HashMap<String, usize>,
440 poisoned: Vec<bool>,
441 render_panic_counts: Vec<AtomicU32>,
445}
446
447impl ExtensionDispatcher {
448 pub fn new(extensions: Vec<Box<dyn WidgetExtension>>) -> Self {
449 let n = extensions.len();
450
451 for ext in &extensions {
453 if ext.config_key().is_empty() {
454 panic!(
455 "extension registered with empty config_key() \
456 (type_names: {:?})",
457 ext.type_names()
458 );
459 }
460 if ext.config_key().contains(':') {
461 panic!(
462 "extension config_key `{}` contains ':' (reserved as \
463 cache namespace separator); type_names: {:?}",
464 ext.config_key(),
465 ext.type_names()
466 );
467 }
468 if ext.type_names().is_empty() {
469 log::warn!(
470 "extension `{}` registered with empty type_names(); \
471 it will never match any node type",
472 ext.config_key()
473 );
474 }
475 }
476
477 let mut seen_config_keys: HashMap<&str, usize> = HashMap::new();
479 for (idx, ext) in extensions.iter().enumerate() {
480 let key = ext.config_key();
481 if let Some(prev_idx) = seen_config_keys.insert(key, idx) {
482 panic!(
483 "duplicate extension config_key `{key}`: \
484 extension at index {prev_idx} (type_names: {:?}) and \
485 extension at index {idx} (type_names: {:?}) both use it",
486 extensions[prev_idx].type_names(),
487 ext.type_names(),
488 );
489 }
490 }
491
492 let mut type_name_index = HashMap::new();
493 for (idx, ext) in extensions.iter().enumerate() {
494 for &name in ext.type_names() {
495 if let Some(prev_idx) = type_name_index.insert(name.to_string(), idx) {
496 panic!(
497 "duplicate extension type name `{name}`: \
498 extension `{}` (index {prev_idx}) and \
499 extension `{}` (index {idx}) both claim it",
500 extensions[prev_idx].config_key(),
501 ext.config_key(),
502 );
503 }
504 }
505 }
506
507 let render_panic_counts = (0..n).map(|_| AtomicU32::new(0)).collect();
508
509 Self {
510 extensions,
511 type_name_index,
512 node_extension_map: HashMap::new(),
513 poisoned: vec![false; n],
514 render_panic_counts,
515 }
516 }
517
518 pub fn clone_for_session(&self) -> Self {
526 let extensions: Vec<Box<dyn WidgetExtension>> = self
527 .extensions
528 .iter()
529 .map(|ext| ext.new_instance())
530 .collect();
531 Self::new(extensions)
532 }
533
534 pub fn handles_type(&self, type_name: &str) -> bool {
536 self.type_name_index.contains_key(type_name)
537 }
538
539 const MAX_WALK_DEPTH: usize = crate::widgets::MAX_TREE_DEPTH;
541
542 pub fn prepare_all(&mut self, root: &TreeNode, caches: &mut ExtensionCaches, theme: &Theme) {
544 let mut new_map = HashMap::new();
545 self.walk_prepare(root, caches, theme, &mut new_map, 0);
546
547 for (old_id, ext_idx) in &self.node_extension_map {
549 if !new_map.contains_key(old_id) {
550 let ns = self.extensions[*ext_idx].config_key().to_string();
551 if self.poisoned[*ext_idx] {
552 caches.remove(&ns, old_id);
553 log::warn!(
554 "skipping cleanup for poisoned extension `{ns}`; \
555 cache entry removed for node `{old_id}`",
556 );
557 } else if catch_unwind_enabled() {
558 let result = catch_unwind(AssertUnwindSafe(|| {
559 self.extensions[*ext_idx].cleanup(old_id, caches);
560 }));
561 if let Err(panic) = result {
562 let msg = panic_message(&panic);
563 log::error!("extension `{ns}` panicked in cleanup: {msg}",);
564 self.poisoned[*ext_idx] = true;
565 caches.remove(&ns, old_id);
566 }
567 } else {
568 self.extensions[*ext_idx].cleanup(old_id, caches);
569 }
570 }
571 }
572
573 self.node_extension_map = new_map;
574
575 for idx in 0..self.extensions.len() {
580 let count = self.render_panic_counts[idx].load(Ordering::Relaxed);
581 if count >= RENDER_PANIC_THRESHOLD && !self.poisoned[idx] {
582 log::error!(
583 "extension `{}` hit {} consecutive render panics, poisoning",
584 self.extensions[idx].config_key(),
585 count,
586 );
587 self.poisoned[idx] = true;
588 }
589 if !self.poisoned[idx] {
590 self.render_panic_counts[idx].store(0, Ordering::Relaxed);
591 }
592 }
593 }
594
595 fn walk_prepare(
596 &mut self,
597 node: &TreeNode,
598 caches: &mut ExtensionCaches,
599 theme: &Theme,
600 map: &mut HashMap<String, usize>,
601 depth: usize,
602 ) {
603 if depth > Self::MAX_WALK_DEPTH {
604 log::warn!(
605 "[id={}] walk_prepare depth exceeds {}, skipping subtree",
606 node.id,
607 Self::MAX_WALK_DEPTH
608 );
609 return;
610 }
611 if let Some(&idx) = self.type_name_index.get(node.type_name.as_str()) {
612 if !self.poisoned[idx] {
613 if catch_unwind_enabled() {
614 let result = catch_unwind(AssertUnwindSafe(|| {
615 self.extensions[idx].prepare(node, caches, theme);
616 }));
617 if let Err(panic) = result {
618 let msg = panic_message(&panic);
619 log::error!(
620 "extension `{}` panicked in prepare: {msg}",
621 self.extensions[idx].config_key()
622 );
623 self.poisoned[idx] = true;
624 }
625 } else {
626 self.extensions[idx].prepare(node, caches, theme);
627 }
628 }
629 map.insert(node.id.clone(), idx);
630 }
631 for child in &node.children {
632 self.walk_prepare(child, caches, theme, map, depth + 1);
633 }
634 }
635
636 pub fn handle_event(
638 &mut self,
639 id: &str,
640 family: &str,
641 data: &Value,
642 caches: &mut ExtensionCaches,
643 ) -> EventResult {
644 let ext_idx = match self.node_extension_map.get(id) {
645 Some(&idx) => idx,
646 None => return EventResult::PassThrough,
647 };
648 if self.poisoned[ext_idx] {
649 log::error!(
650 "extension `{}` is poisoned, dropping event `{family}` for node `{id}`",
651 self.extensions[ext_idx].config_key()
652 );
653 return EventResult::PassThrough;
654 }
655 if catch_unwind_enabled() {
656 match catch_unwind(AssertUnwindSafe(|| {
657 self.extensions[ext_idx].handle_event(id, family, data, caches)
658 })) {
659 Ok(result) => result,
660 Err(panic) => {
661 let msg = panic_message(&panic);
662 log::error!(
663 "extension `{}` panicked in handle_event: {msg}",
664 self.extensions[ext_idx].config_key()
665 );
666 self.poisoned[ext_idx] = true;
667 EventResult::PassThrough
668 }
669 }
670 } else {
671 self.extensions[ext_idx].handle_event(id, family, data, caches)
672 }
673 }
674
675 pub fn handle_command(
677 &mut self,
678 node_id: &str,
679 op: &str,
680 payload: &Value,
681 caches: &mut ExtensionCaches,
682 ) -> Vec<OutgoingEvent> {
683 let ext_idx = match self.node_extension_map.get(node_id) {
684 Some(&idx) => idx,
685 None => {
686 log::warn!("extension command for unknown node `{node_id}`, ignoring");
687 return vec![OutgoingEvent::generic(
688 "extension_error".to_string(),
689 node_id.to_string(),
690 Some(serde_json::json!({
691 "error": format!("no extension handles node `{node_id}`"),
692 "op": op,
693 })),
694 )];
695 }
696 };
697 if self.poisoned[ext_idx] {
698 return vec![OutgoingEvent::generic(
699 "extension_error".to_string(),
700 node_id.to_string(),
701 Some(serde_json::json!({
702 "error": "extension is disabled due to previous panics",
703 "op": op,
704 })),
705 )];
706 }
707 if catch_unwind_enabled() {
708 match catch_unwind(AssertUnwindSafe(|| {
709 self.extensions[ext_idx].handle_command(node_id, op, payload, caches)
710 })) {
711 Ok(events) => events,
712 Err(panic) => {
713 let msg = panic_message(&panic);
714 log::error!(
715 "extension `{}` panicked in handle_command: {msg}",
716 self.extensions[ext_idx].config_key()
717 );
718 self.poisoned[ext_idx] = true;
719 let error_data = serde_json::json!({
721 "error": msg,
722 "op": op,
723 });
724 vec![OutgoingEvent::generic(
725 "extension_error",
726 node_id.to_string(),
727 Some(error_data),
728 )]
729 }
730 }
731 } else {
732 self.extensions[ext_idx].handle_command(node_id, op, payload, caches)
733 }
734 }
735
736 pub fn init_all(&mut self, config: &Value) {
740 for (idx, ext) in self.extensions.iter_mut().enumerate() {
741 if self.poisoned[idx] {
742 continue;
743 }
744 let key = ext.config_key().to_string();
745 let slice = config.get(&key).unwrap_or(&Value::Null);
746 if catch_unwind_enabled() {
747 let result = catch_unwind(AssertUnwindSafe(|| {
748 ext.init(slice);
749 }));
750 if let Err(panic) = result {
751 let msg = panic_message(&panic);
752 log::error!("extension `{key}` panicked in init: {msg}");
753 self.poisoned[idx] = true;
754 }
755 } else {
756 ext.init(slice);
757 }
758 }
759 }
760
761 pub fn render<'a>(
772 &'a self,
773 node: &'a TreeNode,
774 env: &WidgetEnv<'a>,
775 ) -> Option<Element<'a, Message>> {
776 let &idx = self.type_name_index.get(node.type_name.as_str())?;
777 if self.poisoned[idx] {
778 return Some(render_poisoned_placeholder(node));
779 }
780 let element = self.extensions[idx].render(node, env);
781 self.render_panic_counts[idx].store(0, Ordering::Relaxed);
783 Some(element)
784 }
785
786 pub fn record_render_panic(&self, type_name: &str) -> bool {
791 if let Some(&idx) = self.type_name_index.get(type_name) {
792 let prev = self.render_panic_counts[idx].fetch_add(1, Ordering::Relaxed);
793 prev + 1 >= RENDER_PANIC_THRESHOLD
794 } else {
795 false
796 }
797 }
798
799 pub fn clear_poisoned(&mut self) {
801 self.poisoned.fill(false);
802 for counter in &self.render_panic_counts {
803 counter.store(0, Ordering::Relaxed);
804 }
805 }
806
807 pub fn cleanup_all(&mut self, caches: &mut ExtensionCaches) {
813 for (node_id, &ext_idx) in &self.node_extension_map {
814 if self.poisoned[ext_idx] {
815 continue;
816 }
817 if catch_unwind_enabled() {
818 let result = catch_unwind(AssertUnwindSafe(|| {
819 self.extensions[ext_idx].cleanup(node_id, caches);
820 }));
821 if let Err(panic) = result {
822 let msg = panic_message(&panic);
823 log::error!(
824 "extension `{}` panicked in cleanup: {msg}",
825 self.extensions[ext_idx].config_key()
826 );
827 self.poisoned[ext_idx] = true;
828 }
829 } else {
830 self.extensions[ext_idx].cleanup(node_id, caches);
831 }
832 }
833 }
834
835 pub fn reset(&mut self, caches: &mut ExtensionCaches) {
841 self.cleanup_all(caches);
842 self.node_extension_map.clear();
843 caches.clear();
844 self.clear_poisoned();
845 }
846
847 pub fn is_empty(&self) -> bool {
849 self.extensions.is_empty()
850 }
851
852 #[cfg(test)]
854 pub fn is_poisoned(&self, idx: usize) -> bool {
855 self.poisoned.get(idx).copied().unwrap_or(false)
856 }
857
858 pub fn len(&self) -> usize {
860 self.extensions.len()
861 }
862}
863
864impl Default for ExtensionDispatcher {
865 fn default() -> Self {
866 Self::new(vec![])
867 }
868}
869
870#[derive(Debug, Clone)]
902pub struct GenerationCounter {
903 value: u64,
904}
905
906impl GenerationCounter {
907 pub fn new() -> Self {
909 Self { value: 0 }
910 }
911
912 pub fn get(&self) -> u64 {
914 self.value
915 }
916
917 pub fn bump(&mut self) {
919 self.value = self.value.wrapping_add(1);
920 }
921}
922
923impl Default for GenerationCounter {
924 fn default() -> Self {
925 Self::new()
926 }
927}
928
929fn render_poisoned_placeholder<'a>(node: &TreeNode) -> Element<'a, Message> {
934 use iced::Color;
935 use iced::widget::text;
936 text(format!(
937 "Extension error: type `{}`, node `{}`",
938 node.type_name, node.id
939 ))
940 .color(Color::from_rgb(1.0, 0.0, 0.0))
941 .into()
942}
943
944fn panic_message(panic: &Box<dyn Any + Send>) -> String {
945 if let Some(s) = panic.downcast_ref::<&str>() {
946 s.to_string()
947 } else if let Some(s) = panic.downcast_ref::<String>() {
948 s.clone()
949 } else {
950 "unknown panic".to_string()
951 }
952}
953
954#[cfg(test)]
959mod tests {
960 use super::*;
961
962 struct TestExtension {
966 type_names: Vec<&'static str>,
967 config_key: &'static str,
968 init_called: bool,
969 }
970
971 impl TestExtension {
972 fn new(type_names: Vec<&'static str>, config_key: &'static str) -> Self {
973 Self {
974 type_names,
975 config_key,
976 init_called: false,
977 }
978 }
979 }
980
981 impl WidgetExtension for TestExtension {
982 fn type_names(&self) -> &[&str] {
983 &self.type_names
984 }
985
986 fn config_key(&self) -> &str {
987 self.config_key
988 }
989
990 fn init(&mut self, _config: &Value) {
991 self.init_called = true;
992 }
993
994 fn render<'a>(&self, node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
995 use iced::widget::text;
996 text(format!("test:{}", node.id)).into()
997 }
998 }
999
1000 struct EmptyTypesExtension;
1002
1003 impl WidgetExtension for EmptyTypesExtension {
1004 fn type_names(&self) -> &[&str] {
1005 &[]
1006 }
1007 fn config_key(&self) -> &str {
1008 "empty_types"
1009 }
1010 fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
1011 use iced::widget::text;
1012 text("empty").into()
1013 }
1014 }
1015
1016 fn make_node(id: &str, type_name: &str) -> TreeNode {
1017 TreeNode {
1018 id: id.to_string(),
1019 type_name: type_name.to_string(),
1020 props: serde_json::json!({}),
1021 children: vec![],
1022 }
1023 }
1024
1025 #[test]
1028 fn registration_builds_type_name_index() {
1029 let ext = TestExtension::new(vec!["sparkline", "heatmap"], "charts");
1030 let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1031
1032 assert!(dispatcher.handles_type("sparkline"));
1033 assert!(dispatcher.handles_type("heatmap"));
1034 assert!(!dispatcher.handles_type("unknown"));
1035 }
1036
1037 #[test]
1038 fn registration_with_multiple_extensions() {
1039 let ext_a = TestExtension::new(vec!["sparkline"], "charts");
1040 let ext_b = TestExtension::new(vec!["gauge"], "instruments");
1041 let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext_a), Box::new(ext_b)]);
1042
1043 assert!(dispatcher.handles_type("sparkline"));
1044 assert!(dispatcher.handles_type("gauge"));
1045 assert_eq!(dispatcher.len(), 2);
1046 }
1047
1048 #[test]
1049 fn empty_dispatcher_handles_nothing() {
1050 let dispatcher = ExtensionDispatcher::default();
1051 assert!(dispatcher.is_empty());
1052 assert!(!dispatcher.handles_type("anything"));
1053 }
1054
1055 #[test]
1058 #[should_panic(expected = "duplicate extension type name `sparkline`")]
1059 fn duplicate_type_name_panics() {
1060 let ext_a = TestExtension::new(vec!["sparkline"], "charts_a");
1061 let ext_b = TestExtension::new(vec!["sparkline"], "charts_b");
1062 ExtensionDispatcher::new(vec![Box::new(ext_a), Box::new(ext_b)]);
1063 }
1064
1065 #[test]
1066 #[should_panic(expected = "both claim it")]
1067 fn duplicate_type_name_error_identifies_conflicting_extensions() {
1068 let ext_a = TestExtension::new(vec!["widget_x"], "ext_alpha");
1069 let ext_b = TestExtension::new(vec!["widget_x"], "ext_beta");
1070 ExtensionDispatcher::new(vec![Box::new(ext_a), Box::new(ext_b)]);
1071 }
1072
1073 #[test]
1076 #[should_panic(expected = "empty config_key()")]
1077 fn empty_config_key_panics() {
1078 let ext = TestExtension::new(vec!["widget"], "");
1079 ExtensionDispatcher::new(vec![Box::new(ext)]);
1080 }
1081
1082 #[test]
1085 #[should_panic(expected = "contains ':'")]
1086 fn config_key_with_colon_panics() {
1087 let ext = TestExtension::new(vec!["widget"], "bad:key");
1088 ExtensionDispatcher::new(vec![Box::new(ext)]);
1089 }
1090
1091 #[test]
1094 #[should_panic(expected = "duplicate extension config_key `charts`")]
1095 fn duplicate_config_key_panics() {
1096 let ext_a = TestExtension::new(vec!["sparkline"], "charts");
1097 let ext_b = TestExtension::new(vec!["heatmap"], "charts");
1098 ExtensionDispatcher::new(vec![Box::new(ext_a), Box::new(ext_b)]);
1099 }
1100
1101 #[test]
1104 fn empty_type_names_does_not_panic() {
1105 let ext = EmptyTypesExtension;
1107 let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1108 assert_eq!(dispatcher.len(), 1);
1109 assert!(!dispatcher.handles_type("anything"));
1110 }
1111
1112 #[test]
1115 fn cache_insert_and_get() {
1116 let mut caches = ExtensionCaches::new();
1117 caches.insert("charts", "node1", 42u32);
1118
1119 assert_eq!(caches.get::<u32>("charts", "node1"), Some(&42));
1120 assert_eq!(caches.get::<u32>("charts", "node2"), None);
1121 }
1122
1123 #[test]
1124 fn cache_get_mut() {
1125 let mut caches = ExtensionCaches::new();
1126 caches.insert("ns", "key", vec![1, 2, 3]);
1127
1128 if let Some(v) = caches.get_mut::<Vec<i32>>("ns", "key") {
1129 v.push(4);
1130 }
1131 assert_eq!(caches.get::<Vec<i32>>("ns", "key"), Some(&vec![1, 2, 3, 4]));
1132 }
1133
1134 #[test]
1135 fn cache_get_or_insert_creates_default() {
1136 let mut caches = ExtensionCaches::new();
1137 let val = caches.get_or_insert::<String>("ns", "key", || "hello".to_string());
1138 assert_eq!(val, "hello");
1139
1140 let val = caches.get_or_insert::<String>("ns", "key", || "world".to_string());
1142 assert_eq!(val, "hello");
1143 }
1144
1145 #[test]
1146 fn cache_get_or_insert_type_mismatch_replaces_with_default() {
1147 let mut caches = ExtensionCaches::new();
1148 caches.insert("ns", "key", 42u32);
1149 let val = caches.get_or_insert::<String>("ns", "key", || "replaced".to_string());
1152 assert_eq!(val, "replaced");
1153 }
1154
1155 #[test]
1156 fn cache_wrong_type_returns_none() {
1157 let mut caches = ExtensionCaches::new();
1158 caches.insert("ns", "key", 42u32);
1159
1160 assert_eq!(caches.get::<String>("ns", "key"), None);
1162 }
1163
1164 #[test]
1165 fn cache_remove_and_contains() {
1166 let mut caches = ExtensionCaches::new();
1167 caches.insert("ns", "key", 1u8);
1168
1169 assert!(caches.contains("ns", "key"));
1170 assert!(caches.remove("ns", "key"));
1171 assert!(!caches.contains("ns", "key"));
1172 assert!(!caches.remove("ns", "key"));
1173 }
1174
1175 #[test]
1176 fn cache_clear_removes_everything() {
1177 let mut caches = ExtensionCaches::new();
1178 caches.insert("a", "k1", 1u32);
1179 caches.insert("b", "k2", 2u32);
1180
1181 caches.clear();
1182 assert!(!caches.contains("a", "k1"));
1183 assert!(!caches.contains("b", "k2"));
1184 }
1185
1186 #[test]
1189 fn cache_namespace_isolation() {
1190 let mut caches = ExtensionCaches::new();
1191
1192 caches.insert("charts", "data", vec![1.0f64, 2.0, 3.0]);
1194 caches.insert("gauges", "data", 42u32);
1195
1196 assert_eq!(
1197 caches.get::<Vec<f64>>("charts", "data"),
1198 Some(&vec![1.0, 2.0, 3.0])
1199 );
1200 assert_eq!(caches.get::<u32>("gauges", "data"), Some(&42));
1201 }
1202
1203 #[test]
1204 fn cache_remove_namespace() {
1205 let mut caches = ExtensionCaches::new();
1206 caches.insert("charts", "a", 1u32);
1207 caches.insert("charts", "b", 2u32);
1208 caches.insert("gauges", "a", 3u32);
1209
1210 caches.remove_namespace("charts");
1211
1212 assert!(!caches.contains("charts", "a"));
1213 assert!(!caches.contains("charts", "b"));
1214 assert!(caches.contains("gauges", "a"));
1215 }
1216
1217 #[test]
1220 fn poison_flag_set_and_clear() {
1221 let ext = TestExtension::new(vec!["sparkline"], "charts");
1222 let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1223
1224 assert!(!dispatcher.is_poisoned(0));
1225
1226 for _ in 0..RENDER_PANIC_THRESHOLD {
1228 dispatcher.record_render_panic("sparkline");
1229 }
1230
1231 let root = make_node("root", "column");
1233 let mut caches = ExtensionCaches::new();
1234 dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1235
1236 assert!(dispatcher.is_poisoned(0));
1237
1238 dispatcher.clear_poisoned();
1240 assert!(!dispatcher.is_poisoned(0));
1241 }
1242
1243 #[test]
1246 fn record_render_panic_increments_counter() {
1247 let ext = TestExtension::new(vec!["sparkline"], "charts");
1248 let dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1249
1250 assert!(!dispatcher.record_render_panic("sparkline"));
1252 assert!(!dispatcher.record_render_panic("sparkline"));
1253
1254 assert!(dispatcher.record_render_panic("sparkline"));
1256 }
1257
1258 #[test]
1259 fn record_render_panic_unknown_type_returns_false() {
1260 let dispatcher = ExtensionDispatcher::default();
1261 assert!(!dispatcher.record_render_panic("nonexistent"));
1262 }
1263
1264 #[test]
1267 fn event_result_pass_through() {
1268 let result = EventResult::PassThrough;
1269 assert!(matches!(result, EventResult::PassThrough));
1270 }
1271
1272 #[test]
1273 fn event_result_consumed_with_events() {
1274 let events = vec![OutgoingEvent::generic("test", "n1".to_string(), None)];
1275 let result = EventResult::Consumed(events);
1276 match result {
1277 EventResult::Consumed(e) => assert_eq!(e.len(), 1),
1278 _ => panic!("expected Consumed"),
1279 }
1280 }
1281
1282 #[test]
1283 fn event_result_observed_with_events() {
1284 let events = vec![OutgoingEvent::generic("test", "n1".to_string(), None)];
1285 let result = EventResult::Observed(events);
1286 match result {
1287 EventResult::Observed(e) => assert_eq!(e.len(), 1),
1288 _ => panic!("expected Observed"),
1289 }
1290 }
1291
1292 #[test]
1295 fn generation_counter_starts_at_zero() {
1296 let counter = GenerationCounter::new();
1297 assert_eq!(counter.get(), 0);
1298 }
1299
1300 #[test]
1301 fn generation_counter_bumps() {
1302 let mut counter = GenerationCounter::new();
1303 counter.bump();
1304 assert_eq!(counter.get(), 1);
1305 counter.bump();
1306 assert_eq!(counter.get(), 2);
1307 }
1308
1309 #[test]
1310 fn generation_counter_default() {
1311 let counter = GenerationCounter::default();
1312 assert_eq!(counter.get(), 0);
1313 }
1314
1315 #[test]
1318 fn init_all_routes_config_by_key() {
1319 let ext = TestExtension::new(vec!["sparkline"], "charts");
1320 let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1321
1322 let config = serde_json::json!({"charts": {"color": "red"}});
1323 dispatcher.init_all(&config);
1324
1325 assert!(!dispatcher.is_poisoned(0));
1328 }
1329
1330 #[test]
1333 fn panic_message_extracts_str() {
1334 let p: Box<dyn Any + Send> = Box::new("boom");
1335 assert_eq!(panic_message(&p), "boom");
1336 }
1337
1338 #[test]
1339 fn panic_message_extracts_string() {
1340 let p: Box<dyn Any + Send> = Box::new("kaboom".to_string());
1341 assert_eq!(panic_message(&p), "kaboom");
1342 }
1343
1344 #[test]
1345 fn panic_message_unknown_type() {
1346 let p: Box<dyn Any + Send> = Box::new(42u32);
1347 assert_eq!(panic_message(&p), "unknown panic");
1348 }
1349
1350 struct PanickingCommandExtension;
1354
1355 impl WidgetExtension for PanickingCommandExtension {
1356 fn type_names(&self) -> &[&str] {
1357 &["panicker"]
1358 }
1359 fn config_key(&self) -> &str {
1360 "panicker"
1361 }
1362 fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
1363 use iced::widget::text;
1364 text("panicker").into()
1365 }
1366 fn handle_command(
1367 &mut self,
1368 _node_id: &str,
1369 _op: &str,
1370 _payload: &Value,
1371 _caches: &mut ExtensionCaches,
1372 ) -> Vec<OutgoingEvent> {
1373 panic!("command went boom");
1374 }
1375 }
1376
1377 #[test]
1378 fn handle_command_panic_emits_error_event() {
1379 let ext = PanickingCommandExtension;
1380 let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1381 let mut caches = ExtensionCaches::new();
1382
1383 let mut root = make_node("root", "column");
1385 root.children.push(make_node("p1", "panicker"));
1386 dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1387
1388 let events = dispatcher.handle_command("p1", "do_thing", &Value::Null, &mut caches);
1389
1390 assert_eq!(events.len(), 1);
1391 let event = &events[0];
1392 assert_eq!(event.family, "extension_error");
1393 assert_eq!(event.id, "p1");
1394 let data = event.data.as_ref().expect("should have data");
1395 assert_eq!(
1396 data.get("error").and_then(|v| v.as_str()),
1397 Some("command went boom")
1398 );
1399 assert_eq!(data.get("op").and_then(|v| v.as_str()), Some("do_thing"));
1400
1401 assert!(dispatcher.is_poisoned(0));
1403 }
1404
1405 #[test]
1406 fn handle_command_poisoned_returns_error_event() {
1407 let ext = PanickingCommandExtension;
1408 let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1409 let mut caches = ExtensionCaches::new();
1410
1411 let mut root = make_node("root", "column");
1413 root.children.push(make_node("p1", "panicker"));
1414 dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1415
1416 for _ in 0..RENDER_PANIC_THRESHOLD {
1418 dispatcher.record_render_panic("panicker");
1419 }
1420 dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1421 assert!(dispatcher.is_poisoned(0));
1422
1423 let events = dispatcher.handle_command("p1", "do_thing", &Value::Null, &mut caches);
1425 assert_eq!(events.len(), 1);
1426 let event = &events[0];
1427 assert_eq!(event.family, "extension_error");
1428 assert_eq!(event.id, "p1");
1429 let data = event.data.as_ref().expect("should have data");
1430 assert_eq!(
1431 data.get("error").and_then(|v| v.as_str()),
1432 Some("extension is disabled due to previous panics")
1433 );
1434 assert_eq!(data.get("op").and_then(|v| v.as_str()), Some("do_thing"));
1435 }
1436
1437 struct CleanupTracker {
1441 cleaned_ids: std::sync::Arc<std::sync::Mutex<Vec<String>>>,
1442 }
1443
1444 impl CleanupTracker {
1445 fn new(tracker: std::sync::Arc<std::sync::Mutex<Vec<String>>>) -> Self {
1446 Self {
1447 cleaned_ids: tracker,
1448 }
1449 }
1450 }
1451
1452 impl WidgetExtension for CleanupTracker {
1453 fn type_names(&self) -> &[&str] {
1454 &["tracked"]
1455 }
1456 fn config_key(&self) -> &str {
1457 "tracker"
1458 }
1459 fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
1460 use iced::widget::text;
1461 text("tracked").into()
1462 }
1463 fn cleanup(&mut self, node_id: &str, _caches: &mut ExtensionCaches) {
1464 self.cleaned_ids.lock().unwrap().push(node_id.to_string());
1465 }
1466 }
1467
1468 #[test]
1469 fn cleanup_all_calls_cleanup_for_tracked_nodes() {
1470 let tracker = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1471 let ext = CleanupTracker::new(tracker.clone());
1472 let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1473 let mut caches = ExtensionCaches::new();
1474
1475 let mut root = make_node("root", "column");
1477 root.children.push(make_node("t1", "tracked"));
1478 root.children.push(make_node("t2", "tracked"));
1479 dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1480
1481 dispatcher.cleanup_all(&mut caches);
1483 let cleaned = tracker.lock().unwrap();
1484 assert!(cleaned.contains(&"t1".to_string()));
1485 assert!(cleaned.contains(&"t2".to_string()));
1486 assert_eq!(cleaned.len(), 2);
1487 }
1488
1489 #[test]
1490 fn cleanup_all_skips_poisoned_extensions() {
1491 let tracker = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
1492 let ext = CleanupTracker::new(tracker.clone());
1493 let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1494 let mut caches = ExtensionCaches::new();
1495
1496 let mut root = make_node("root", "column");
1497 root.children.push(make_node("t1", "tracked"));
1498 dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1499
1500 for _ in 0..RENDER_PANIC_THRESHOLD {
1502 dispatcher.record_render_panic("tracked");
1503 }
1504 dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1505 assert!(dispatcher.is_poisoned(0));
1506
1507 dispatcher.cleanup_all(&mut caches);
1509 assert!(tracker.lock().unwrap().is_empty());
1510 }
1511
1512 #[test]
1515 fn reset_clears_node_map_and_caches() {
1516 let ext = TestExtension::new(vec!["sparkline"], "charts");
1517 let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1518 let mut caches = ExtensionCaches::new();
1519
1520 let mut root = make_node("root", "column");
1522 root.children.push(make_node("s1", "sparkline"));
1523 dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1524 caches.insert("charts", "s1", 42u32);
1525 assert!(caches.contains("charts", "s1"));
1526
1527 dispatcher.reset(&mut caches);
1529
1530 assert!(!caches.contains("charts", "s1"));
1531 assert!(!dispatcher.is_poisoned(0));
1532 let result = dispatcher.handle_event("s1", "click", &Value::Null, &mut caches);
1535 assert!(matches!(result, EventResult::PassThrough));
1536 }
1537
1538 #[test]
1539 fn reset_clears_poisoned_state() {
1540 let ext = TestExtension::new(vec!["sparkline"], "charts");
1541 let mut dispatcher = ExtensionDispatcher::new(vec![Box::new(ext)]);
1542 let mut caches = ExtensionCaches::new();
1543
1544 for _ in 0..RENDER_PANIC_THRESHOLD {
1546 dispatcher.record_render_panic("sparkline");
1547 }
1548 let root = make_node("root", "column");
1549 dispatcher.prepare_all(&root, &mut caches, &Theme::Dark);
1550 assert!(dispatcher.is_poisoned(0));
1551
1552 dispatcher.reset(&mut caches);
1554 assert!(!dispatcher.is_poisoned(0));
1555 }
1556
1557 struct PanickingRenderExtension;
1561
1562 impl WidgetExtension for PanickingRenderExtension {
1563 fn type_names(&self) -> &[&str] {
1564 &["panicky_render"]
1565 }
1566 fn config_key(&self) -> &str {
1567 "panicky_render"
1568 }
1569 fn render<'a>(&self, _node: &'a TreeNode, _env: &WidgetEnv<'a>) -> Element<'a, Message> {
1570 panic!("render goes boom");
1571 }
1572 }
1573
1574 #[test]
1575 fn poison_lifecycle_render_panics_then_clear() {
1576 let ext: Box<dyn WidgetExtension> = Box::new(PanickingRenderExtension);
1577 let mut dispatcher = ExtensionDispatcher::new(vec![ext]);
1578 let mut caches = ExtensionCaches::new();
1579 let images = crate::image_registry::ImageRegistry::new();
1580 let theme = Theme::Dark;
1581
1582 let mut root = make_node("root", "column");
1584 root.children.push(make_node("pr1", "panicky_render"));
1585 dispatcher.prepare_all(&root, &mut caches, &theme);
1586
1587 assert!(!dispatcher.is_poisoned(0));
1589
1590 for i in 0..RENDER_PANIC_THRESHOLD {
1594 let at_threshold = dispatcher.record_render_panic("panicky_render");
1595 if i < RENDER_PANIC_THRESHOLD - 1 {
1596 assert!(!at_threshold, "should not be at threshold yet (i={i})");
1597 } else {
1598 assert!(at_threshold, "should be at threshold now");
1599 }
1600 }
1601
1602 dispatcher.prepare_all(&root, &mut caches, &theme);
1604 assert!(
1605 dispatcher.is_poisoned(0),
1606 "extension should be poisoned after threshold + prepare_all"
1607 );
1608
1609 let node = make_node("pr1", "panicky_render");
1612 {
1613 let widget_caches = crate::widgets::WidgetCaches::new();
1614 let ctx = RenderCtx {
1615 caches: &widget_caches,
1616 images: &images,
1617 theme: &theme,
1618 extensions: &dispatcher,
1619 default_text_size: None,
1620 default_font: None,
1621 };
1622 let env = WidgetEnv {
1623 caches: &caches,
1624 ctx,
1625 };
1626 let result = dispatcher.render(&node, &env);
1627 assert!(
1628 result.is_some(),
1629 "poisoned extension should still return Some (placeholder)"
1630 );
1631 } dispatcher.clear_poisoned();
1635 assert!(
1636 !dispatcher.is_poisoned(0),
1637 "poison should be cleared after clear_poisoned"
1638 );
1639
1640 let widget_caches2 = crate::widgets::WidgetCaches::new();
1646 let ctx2 = RenderCtx {
1647 caches: &widget_caches2,
1648 images: &images,
1649 theme: &theme,
1650 extensions: &dispatcher,
1651 default_text_size: None,
1652 default_font: None,
1653 };
1654 let env2 = WidgetEnv {
1655 caches: &caches,
1656 ctx: ctx2,
1657 };
1658 let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1659 dispatcher.render(&node, &env2)
1660 }));
1661 assert!(
1662 result.is_err(),
1663 "after clearing poison, render should call the extension again (which panics)"
1664 );
1665 }
1666}