forked from 1b-agent/opencadstudio-upstream
Plugins can now declare the entity classes they identify via XDATA, and the
host resolves them to display-only type labels while the declaring plugin is
loaded.
- Append `BuiltinPlugin::entity_classes()` (a guarded trailing vtable slot,
the same additive pattern as `on_load` in v5) returning a &'static
`EntityClasses { app_id, class_prefix, labels }`. The static
`PluginManifest` struct is deliberately untouched: older cdylibs share it
by pointer and its layout is frozen ABI since v1, so adding a field would
read past their allocation.
- `OwnedEntityClasses` travels on the registered manifest as
`OwnedPluginManifest.entity_classes` with `#[serde(default)]`, so manifests
serialized before the field keep decoding (older runners tolerate it
both ways).
- The runner folds the declaration into the GetManifest registration only
when the loaded cdylib's exported `ocs_plugin_api_version` symbol reports
>= 10 — an older binary's vtable predates the slot, so it is never called;
its entities simply keep their native type names.
- `PluginManager::entity_class_declarations` exposes the live set, filtered
to alive, non-disabled plugins in load order. The registered manifests are
the only source and nothing is cached: not installed, disabled,
cap-excluded (`OCS_PLUGIN_MAX_API_VERSION`), failed at load, removed, or a
dead process all contribute nothing.
- Embedded version info `api_version` follows the bump to 10;
`API_VERSION_MIN_SUPPORTED` stays 3.
Tests: runner fold (v10 folds, pre-v10 skipped, `entity_classes()` panic is
contained and reported), manager filtering (disabled / dead / undeclaring
skipped; declarations disappear once the process dies — verified with a real
killed child), bincode registration round-trip, and older-manifest serde
default.
484 lines
17 KiB
Rust
484 lines
17 KiB
Rust
//! Process manager for out-of-process plugins.
|
|
//!
|
|
//! `PluginManager` is the host's owner of every loaded plugin process. It is
|
|
//! responsible for:
|
|
//!
|
|
//! - Spawning plugins via [`PluginProcess::spawn`] and building their ribbon
|
|
//! modules.
|
|
//! - Routing commands to plugins through [`PluginManager::dispatch`].
|
|
//! - Broadcasting host notifications to every alive V4 plugin via
|
|
//! [`PluginManager::broadcast_notification`].
|
|
//! - Surfacing dead plugins and per-plugin errors in [`DispatchResult`].
|
|
//!
|
|
//! Notifications are V4-only: `broadcast_notification` skips non-V4 processes.
|
|
//! The plugin-to-host notification handler (set via
|
|
//! [`PluginManager::set_notification_handler`]) runs on the V4 reader thread
|
|
//! and must not block. For heavy work, forward the notification to a channel.
|
|
|
|
use std::path::Path;
|
|
use std::sync::Arc;
|
|
use std::time::Duration;
|
|
|
|
use crate::host::{HostApi, HostNotification, PluginNotification};
|
|
use crate::process::{PluginError, PluginProcess};
|
|
use crate::ribbon::owned::{to_shared_module, OwnedEntityClasses, SharedCadModule};
|
|
|
|
/// Callback the host installs to receive plugin-to-host notifications.
|
|
pub type NotificationHandler = Arc<dyn Fn(&str, Option<u64>, PluginNotification) + Send + Sync>;
|
|
|
|
/// Owner of every spawned plugin process.
|
|
pub struct PluginManager {
|
|
plugins: Vec<LoadedPlugin>,
|
|
notification_handler: Option<NotificationHandler>,
|
|
}
|
|
|
|
struct LoadedPlugin {
|
|
process: Arc<PluginProcess>,
|
|
module: SharedCadModule,
|
|
}
|
|
|
|
impl Default for PluginManager {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
/// Outcome of [`PluginManager::dispatch`].
|
|
#[derive(Default)]
|
|
pub struct DispatchResult {
|
|
/// A plugin handled the command.
|
|
pub handled: bool,
|
|
/// An interactive command was started by a plugin.
|
|
pub started: Option<(Arc<PluginProcess>, u64)>,
|
|
/// Plugins whose process died before or during dispatch.
|
|
pub dead_plugins: Vec<String>,
|
|
/// Plugins that returned an error while trying to handle the command.
|
|
pub errors: Vec<(String, String)>,
|
|
}
|
|
|
|
impl PluginManager {
|
|
/// Create an empty manager.
|
|
pub fn new() -> Self {
|
|
Self {
|
|
plugins: Vec::new(),
|
|
notification_handler: None,
|
|
}
|
|
}
|
|
|
|
/// Install a handler for plugin-to-host notifications. The handler runs on
|
|
/// the V4 reader thread and must not block or allocate heavily; forward to
|
|
/// a channel if heavy work is required. Should be set before [`load`] is
|
|
/// called so notifications from newly loaded plugins are delivered.
|
|
pub fn set_notification_handler(&mut self, handler: NotificationHandler) {
|
|
self.notification_handler = Some(handler);
|
|
}
|
|
|
|
/// Spawn `cdylib_path` as a separate plugin process, build its ribbon
|
|
/// module, and store it. Returns the plugin id on success.
|
|
pub fn load(
|
|
&mut self,
|
|
cdylib_path: &Path,
|
|
host: &mut dyn HostApi,
|
|
) -> Result<String, PluginError> {
|
|
let handler: NotificationHandler = match self.notification_handler.as_ref() {
|
|
Some(h) => Arc::clone(h),
|
|
None => Arc::new(|_id, _cmd, _notif| {}),
|
|
};
|
|
let process = PluginProcess::spawn(cdylib_path, host, handler)?;
|
|
let id = process.id().to_string();
|
|
let name = process.manifest().name.clone();
|
|
let module = to_shared_module(id.clone(), name, process.ribbon().to_vec());
|
|
self.plugins.push(LoadedPlugin {
|
|
process: Arc::new(process),
|
|
module,
|
|
});
|
|
Ok(id)
|
|
}
|
|
|
|
/// Fan out a host notification to every alive V4 plugin. Per-process errors
|
|
/// are logged; this is best-effort.
|
|
pub fn broadcast_notification(&self, command_id: Option<u64>, notification: HostNotification) {
|
|
for p in &self.plugins {
|
|
if !p.process.is_alive() {
|
|
continue;
|
|
}
|
|
if !p.process.is_v4() {
|
|
if crate::process::verbose() {
|
|
eprintln!(
|
|
"[plugin] skipping notification for {} (not V4)",
|
|
p.process.id()
|
|
);
|
|
}
|
|
continue;
|
|
}
|
|
if let Err(e) = p.process.notify_plugin(command_id, notification.clone()) {
|
|
eprintln!(
|
|
"[plugin] broadcast_notification failed for {}: {e}",
|
|
p.process.id()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ribbon modules for alive, non-disabled plugins, sorted by `ribbon_order`.
|
|
pub fn ribbon_modules<F: Fn(&str) -> bool>(
|
|
&self,
|
|
is_disabled: F,
|
|
) -> Vec<(i32, SharedCadModule)> {
|
|
let mut out: Vec<(i32, SharedCadModule)> = self
|
|
.plugins
|
|
.iter()
|
|
.filter(|p| !is_disabled(p.process.id()) && p.process.is_alive())
|
|
.map(|p| (p.process.manifest().ribbon_order, p.module.clone()))
|
|
.collect();
|
|
out.sort_by_key(|(order, _)| *order);
|
|
out
|
|
}
|
|
|
|
/// Dispatch `cmd` to each plugin until one handles it.
|
|
///
|
|
/// `is_disabled` is called for each plugin id so the host can filter
|
|
/// disabled plugins without exposing its set type to the crate.
|
|
pub fn dispatch<F: Fn(&str) -> bool>(
|
|
&self,
|
|
host: &mut dyn HostApi,
|
|
cmd: &str,
|
|
is_disabled: F,
|
|
) -> DispatchResult {
|
|
let mut result = DispatchResult::default();
|
|
for p in &self.plugins {
|
|
let id = p.process.id().to_string();
|
|
if is_disabled(&id) {
|
|
continue;
|
|
}
|
|
if !p.process.is_alive() {
|
|
result.dead_plugins.push(id);
|
|
continue;
|
|
}
|
|
let process = Arc::clone(&p.process);
|
|
let mut on_start = |command_id: u64| {
|
|
result.started = Some((Arc::clone(&process), command_id));
|
|
};
|
|
match p.process.dispatch(host, cmd, &mut on_start) {
|
|
Ok(true) => {
|
|
result.handled = true;
|
|
return result;
|
|
}
|
|
Ok(false) => {}
|
|
Err(e) => result.errors.push((id, e.to_string())),
|
|
}
|
|
}
|
|
result
|
|
}
|
|
|
|
/// Plugin ids currently loaded.
|
|
pub fn ids(&self) -> Vec<String> {
|
|
self.plugins
|
|
.iter()
|
|
.map(|p| p.process.id().to_string())
|
|
.collect()
|
|
}
|
|
|
|
/// Command names advertised by every alive, non-disabled plugin: each
|
|
/// ribbon tool's command id plus the manifest's `command_prefixes`. The
|
|
/// host merges these into command-line autocomplete so plugin commands are
|
|
/// discoverable by typing (#272).
|
|
pub fn command_names<F: Fn(&str) -> bool>(&self, is_disabled: F) -> Vec<String> {
|
|
let mut out = Vec::new();
|
|
for p in &self.plugins {
|
|
if is_disabled(p.process.id()) || !p.process.is_alive() {
|
|
continue;
|
|
}
|
|
for group in p.process.ribbon() {
|
|
out.append(&mut group.command_ids());
|
|
}
|
|
out.extend(p.process.manifest().command_prefixes.iter().cloned());
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Entity-class declarations of every alive, non-disabled plugin that
|
|
/// made one (API v10), in load order, as `(plugin id, declaration)`.
|
|
///
|
|
/// The host consults this at display time only — never cached — so the
|
|
/// result always mirrors the live loaded set: a plugin that failed to
|
|
/// load, is disabled, was removed, or whose process has died contributes
|
|
/// nothing, and entities it stamped fall back to their native type names.
|
|
pub fn entity_class_declarations<F: Fn(&str) -> bool>(
|
|
&self,
|
|
is_disabled: F,
|
|
) -> Vec<(String, OwnedEntityClasses)> {
|
|
let mut out = Vec::new();
|
|
for p in &self.plugins {
|
|
if is_disabled(p.process.id()) || !p.process.is_alive() {
|
|
continue;
|
|
}
|
|
if let Some(classes) = p.process.manifest().entity_classes.clone() {
|
|
out.push((p.process.id().to_string(), classes));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Drain any plugin→host requests that have arrived asynchronously across
|
|
/// all loaded plugins. Errors are logged; this is best-effort.
|
|
pub fn drain_requests(
|
|
&self,
|
|
host: &mut dyn HostApi,
|
|
on_start_interactive: &mut dyn FnMut(u64),
|
|
) {
|
|
for p in &self.plugins {
|
|
if !p.process.is_alive() {
|
|
continue;
|
|
}
|
|
if let Err(e) = p.process.drain_requests(host, on_start_interactive) {
|
|
eprintln!("[plugin] drain_requests failed for {}: {e}", p.process.id());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Drain any plugin stdout/stderr lines that have accumulated across all
|
|
/// loaded plugins. Errors are logged; this is best-effort.
|
|
pub fn drain_io(&self) -> Vec<crate::process::PluginIoLine> {
|
|
let mut out = Vec::new();
|
|
for p in &self.plugins {
|
|
if !p.process.is_alive() {
|
|
continue;
|
|
}
|
|
out.extend(p.process.drain_io());
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Shut down and remove the plugin with `id` from the manager.
|
|
///
|
|
/// Returns `true` if a plugin with that id was found and removed. The
|
|
/// process is killed and waited on so its DLL is released; this is intended
|
|
/// for uninstall, where the caller needs the files to become deletable on
|
|
/// Windows.
|
|
pub fn remove(&mut self, id: &str) -> bool {
|
|
let Some(index) = self.plugins.iter().position(|p| p.process.id() == id) else {
|
|
return false;
|
|
};
|
|
let plugin = self.plugins.remove(index);
|
|
plugin
|
|
.process
|
|
.shutdown_and_wait(Duration::from_secs(5))
|
|
.is_some()
|
|
}
|
|
|
|
/// Begin asynchronous shutdown of every plugin process.
|
|
///
|
|
/// Kills every child synchronously on the calling thread and moves the
|
|
/// blocking `wait()` calls into a single detached reaper thread, so host
|
|
/// shutdown is fast regardless of how many plugins are loaded.
|
|
pub fn shutdown_all(&mut self) {
|
|
let plugins = std::mem::take(&mut self.plugins);
|
|
for p in plugins {
|
|
p.process.shutdown();
|
|
}
|
|
}
|
|
}
|
|
|
|
impl Drop for PluginManager {
|
|
fn drop(&mut self) {
|
|
self.shutdown_all();
|
|
}
|
|
}
|
|
|
|
#[cfg(all(test, feature = "host"))]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn empty_manager_has_no_ribbon_modules() {
|
|
let manager = PluginManager::new();
|
|
assert!(manager.ribbon_modules(|_| false).is_empty());
|
|
assert!(manager.ids().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn dispatch_with_no_plugins_is_not_handled() {
|
|
struct EmptyReader;
|
|
impl crate::host::DocumentReader for EmptyReader {
|
|
fn entity_count(&self) -> usize {
|
|
0
|
|
}
|
|
fn for_each_entity(&self, _f: &mut dyn FnMut(crate::host::ReaderEntity<'_>)) {}
|
|
fn layer_name(&self, _handle: codec::Handle) -> Option<&str> {
|
|
None
|
|
}
|
|
fn app_id_name(&self, _handle: codec::Handle) -> Option<&str> {
|
|
None
|
|
}
|
|
}
|
|
|
|
struct DummyHost;
|
|
impl HostApi for DummyHost {
|
|
fn tab_index(&self) -> usize {
|
|
0
|
|
}
|
|
fn document(&self) -> &codec::CadDocument {
|
|
panic!("not used")
|
|
}
|
|
fn document_mut(&mut self) -> &mut codec::CadDocument {
|
|
panic!("not used")
|
|
}
|
|
fn document_reader(&self) -> Box<dyn crate::host::DocumentReader + '_> {
|
|
Box::new(EmptyReader)
|
|
}
|
|
fn add_entity(&mut self, _entity: codec::EntityType) -> codec::Handle {
|
|
panic!("not used")
|
|
}
|
|
fn bump_geometry(&mut self) {}
|
|
fn read_record(
|
|
&self,
|
|
_handle: codec::Handle,
|
|
_app_name: &str,
|
|
) -> Option<&codec::xdata::ExtendedDataRecord> {
|
|
None
|
|
}
|
|
fn write_record(
|
|
&mut self,
|
|
_handle: codec::Handle,
|
|
_record: codec::xdata::ExtendedDataRecord,
|
|
) -> bool {
|
|
false
|
|
}
|
|
fn remove_record(&mut self, _handle: codec::Handle, _app_name: &str) -> bool {
|
|
false
|
|
}
|
|
fn push_undo(&mut self, _label: &str) {}
|
|
fn set_dirty(&mut self) {}
|
|
fn push_info(&mut self, _msg: &str) {}
|
|
fn push_output(&mut self, _msg: &str) {}
|
|
fn push_error(&mut self, _msg: &str) {}
|
|
fn start_interactive(&mut self, _command: Box<dyn crate::host::InteractiveCommand>) {}
|
|
fn plugin_state_any(
|
|
&self,
|
|
_plugin_id: &str,
|
|
) -> Option<&(dyn std::any::Any + Send + Sync)> {
|
|
None
|
|
}
|
|
fn plugin_state_any_mut(
|
|
&mut self,
|
|
_plugin_id: &str,
|
|
) -> Option<&mut (dyn std::any::Any + Send + Sync)> {
|
|
None
|
|
}
|
|
fn ensure_plugin_state_any(
|
|
&mut self,
|
|
_plugin_id: &'static str,
|
|
_init: &mut dyn FnMut() -> Box<dyn std::any::Any + Send + Sync>,
|
|
) -> Option<&mut (dyn std::any::Any + Send + Sync)> {
|
|
None
|
|
}
|
|
}
|
|
|
|
let manager = PluginManager::new();
|
|
let mut host = DummyHost;
|
|
let result = manager.dispatch(&mut host, "FOO", |_| false);
|
|
assert!(!result.handled);
|
|
assert!(result.started.is_none());
|
|
}
|
|
|
|
fn manifest_with_entity_classes(id: &str) -> crate::ribbon::owned::OwnedPluginManifest {
|
|
crate::ribbon::owned::OwnedPluginManifest {
|
|
id: id.to_string(),
|
|
name: format!("{id} name"),
|
|
version: "0.1.0".to_string(),
|
|
description: "test".to_string(),
|
|
api_version: 10,
|
|
ribbon_order: 0,
|
|
xdata_apps: vec![],
|
|
command_prefixes: vec![],
|
|
entity_classes: Some(OwnedEntityClasses {
|
|
app_id: "CVIFC".to_string(),
|
|
class_prefix: "CVIFC-CLASS:".to_string(),
|
|
labels: [("LINE".to_string(), "Linear Element".to_string())]
|
|
.into_iter()
|
|
.collect(),
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn plain_manifest(id: &str) -> crate::ribbon::owned::OwnedPluginManifest {
|
|
crate::ribbon::owned::OwnedPluginManifest {
|
|
id: id.to_string(),
|
|
name: format!("{id} name"),
|
|
version: "0.1.0".to_string(),
|
|
description: "test".to_string(),
|
|
api_version: 10,
|
|
ribbon_order: 0,
|
|
xdata_apps: vec![],
|
|
command_prefixes: vec![],
|
|
entity_classes: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn entity_class_declarations_skip_disabled_and_undeclaring_plugins() {
|
|
let mut manager = PluginManager::new();
|
|
manager.plugins.push(LoadedPlugin {
|
|
process: Arc::new(PluginProcess::fake(manifest_with_entity_classes(
|
|
"test.live",
|
|
))),
|
|
module: to_shared_module("test.live".into(), "Live".into(), vec![]),
|
|
});
|
|
manager.plugins.push(LoadedPlugin {
|
|
process: Arc::new(PluginProcess::fake(plain_manifest("test.plain"))),
|
|
module: to_shared_module("test.plain".into(), "Plain".into(), vec![]),
|
|
});
|
|
manager.plugins.push(LoadedPlugin {
|
|
process: Arc::new(PluginProcess::fake(manifest_with_entity_classes(
|
|
"test.off",
|
|
))),
|
|
module: to_shared_module("test.off".into(), "Off".into(), vec![]),
|
|
});
|
|
|
|
// Alive + enabled + declaring → reported, with the declaration intact.
|
|
let declarations = manager.entity_class_declarations(|id| id == "test.off");
|
|
assert_eq!(
|
|
declarations.len(),
|
|
1,
|
|
"only the live, enabled plugin reports"
|
|
);
|
|
assert_eq!(declarations[0].0, "test.live");
|
|
assert_eq!(declarations[0].1.app_id, "CVIFC");
|
|
assert_eq!(declarations[0].1.class_prefix, "CVIFC-CLASS:");
|
|
assert_eq!(
|
|
declarations[0].1.labels.get("LINE").map(String::as_str),
|
|
Some("Linear Element")
|
|
);
|
|
|
|
// Everything disabled → nothing contributes.
|
|
assert!(manager.entity_class_declarations(|_| true).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn entity_class_declarations_revert_when_the_plugin_process_dies() {
|
|
let mut manager = PluginManager::new();
|
|
manager.plugins.push(LoadedPlugin {
|
|
process: Arc::new(PluginProcess::fake(manifest_with_entity_classes(
|
|
"test.live",
|
|
))),
|
|
module: to_shared_module("test.live".into(), "Live".into(), vec![]),
|
|
});
|
|
assert_eq!(
|
|
manager.entity_class_declarations(|_| false).len(),
|
|
1,
|
|
"declaration visible while the process is alive"
|
|
);
|
|
|
|
manager.plugins[0]
|
|
.process
|
|
.shutdown_and_wait(Duration::from_secs(5))
|
|
.expect("child shut down");
|
|
|
|
assert!(
|
|
manager.entity_class_declarations(|_| false).is_empty(),
|
|
"a dead plugin process must contribute no declarations"
|
|
);
|
|
}
|
|
}
|