forked from 1b-agent/opencadstudio-upstream
The codec and kernel repositories and crates are renamed opencadcodec and opencadkernel. Inside OCS they are depended on under short keys, so code reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node graph engine). File-format and protocol spellings stay as they were: the ACIS product id `acadrust`, the ACADRUST_* switches and the plugin version keys `acadrust_version` / `acadrust_source`. A button left of MCP on the command line opens a translucent node graph over the viewport, with a palette of object, input, math, logic, list, text and geometry nodes added by click or drag. An object node owns drawing entities and its rows are the Properties panel's own rows for them: the single-object page is extracted from refresh_properties as entity_property_sections, values use the control protocol's property_json, and writes go through set_property_value with a property target override, so the panel, MCP and the graph share one read/write path. A list reaching an object row makes one entity per element. Each graph action is one undo step; the canvas pans and zooms. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
366 lines
12 KiB
Rust
366 lines
12 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, 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
|
|
}
|
|
|
|
/// 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>,
|
|
) -> &mut (dyn std::any::Any + Send + Sync) {
|
|
panic!("not used")
|
|
}
|
|
}
|
|
|
|
let manager = PluginManager::new();
|
|
let mut host = DummyHost;
|
|
let result = manager.dispatch(&mut host, "FOO", |_| false);
|
|
assert!(!result.handled);
|
|
assert!(result.started.is_none());
|
|
}
|
|
}
|