fix(plugins): degrade ensure_plugin_state instead of panicking

Bug:
The typed ensure_plugin_state helper and the object-safe trait method
it wraps turned two recoverable conditions into process deaths. On the
in-process host, the helper downcast the stored state with
.expect("plugin state type mismatch for plugin_id"), so registering
state under one plugin_id as one type and later asking for another
(e.g. a reloaded plugin version changes the type while the per-tab
state map survives) panicked the host. On out-of-process hosts
(ipc/client.rs, ipc/v4/client.rs) and on NullHost, the trait method
itself was implemented as a bare panic!("ensure_plugin_state is not
supported for out-of-process plugins..."), so any out-of-process
plugin calling the documented helper crashed its own runner process -
even though the sibling plugin_state_any and plugin_state_any_mut
methods on those same implementations already degrade to None
gracefully. The helper also used .expect("init called once") on the
initialiser closure.

Reproduce:
1. Out-of-process: a plugin whose runner calls ensure_plugin_state
   (&mut host, "my.id", ...).
2. Type mismatch: in an in-process plugin, ensure state under one id
   as u32, then ensure a different type under the same id.
3. Before: step 1 kills the plugin runner at the trait panic; step 2
   kills the host at the downcast expect. After: both get None, keep
   running, and existing state is untouched.

Fix:
Give the trait method a failure channel: ensure_plugin_state_any now
returns Option<&mut (dyn Any + Send + Sync)>, mirroring the sibling
plugin_state_any_mut on the same implementations, and the typed helper
returns Option<&mut T> (any?.downcast_mut). The panicking
implementations (ipc v3, ipc v4, NullHost, and the three never-called
test dummies) return None; the in-process app host wraps its real
storage in Some with unchanged insert-or-return semantics. The init
closure is now guarded by take() with a unit-Any fallback, so even an
implementation invoking it twice fails the typed downcast instead of
panicking. Object safety, argument types and vtable slot are
unchanged; external plugin sources get a compile error at the helper
and adopt Option handling on rebuild. Regression tests use catch_unwind
so they compile against both signatures: one per panic site (out-of-
process, NullHost, type mismatch), each additionally asserting the
None result and that pre-existing state survived.
This commit is contained in:
Karim Jerbi committed 2026-09-30 19:15:50 +01:00
1 parent c95d42a796
commit b1f4047f9c
7 files changed
+89 -26

No files matched your search

+20 -4
View File
@@ -128,8 +128,8 @@ impl HostApi for NullHost {
&mut self,
_plugin_id: &'static str,
_init: &mut dyn FnMut() -> Box<dyn std::any::Any + Send + Sync>,
) -> &mut (dyn std::any::Any + Send + Sync) {
panic!("NullHost: ensure_plugin_state_any not available")
) -> Option<&mut (dyn std::any::Any + Send + Sync)> {
None
}
}
@@ -1158,6 +1158,22 @@ mod tests {
let runner = distinct_runner_path(&host);
assert_eq!(runner, PathBuf::from("/app/OpenCADStudio-plugin-runner"));
}
#[test]
fn null_host_ensure_plugin_state_does_not_panic() {
let mut null = NullHost;
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
let _ = null.ensure_plugin_state_any("opencad.demo", &mut || Box::new(7u32));
}));
assert!(
outcome.is_ok(),
"NullHost ensure_plugin_state must degrade, not panic: {:?}",
outcome.err()
);
assert!(null
.ensure_plugin_state_any("opencad.demo", &mut || Box::new(7u32))
.is_none());
}
}
#[cfg(all(test, feature = "host"))]
@@ -1261,8 +1277,8 @@ mod timeout_tests {
&mut self,
_plugin_id: &'static str,
_init: &mut dyn FnMut() -> Box<dyn std::any::Any + Send + Sync>,
) -> &mut (dyn std::any::Any + Send + Sync) {
panic!("not used")
) -> Option<&mut (dyn std::any::Any + Send + Sync)> {
None
}
}