Files
OneCAD-Draft/crates/ocs_plugin_api/src/runner.rs
T
Karim Jerbi 73b05e1dec perf(core): cache ribbon_groups() with OnceLock, avoid per-frame tree allocations
Change CadModule::ribbon_groups() from -> Vec<RibbonGroup> to -> &[RibbonGroup],
backed by a per-module OnceLock cache. The ribbon group tree is static data
(&'static str, Copy enums) that was being fully reconstructed — Vecs, String
clones, enum discriminants — on every call, including all three per-frame
call sites (view(), dropdown_overlay(), style_combo_overlay()).

- 7 built-in modules + 2 plugin templates cache via function-local OnceLock.
  Safe because each is a unit struct with exactly one instance per process
  (plugins run in isolated child processes; see PluginProcess::spawn()).
- SharedCadModule avoids the static pattern entirely, storing groups in an
  instance field (owned.rs).
- IPC runner converts &RibbonGroup -> OwnedRibbonGroup directly via new
  From<&T> impls, avoiding an intermediate clone.
- render_small/render_large and the two make_tool_row closures now borrow
  (&RibbonItem, &[ToolDef]) instead of taking ownership, so the view loop
  doesn't need to clone items to satisfy the old by-value signatures.
- Various match-ergonomics deref fixes (*id, *default, *icon, etc.) from
  the &RibbonItem pattern change.
2026-07-02 21:00:57 +01:00

188 lines
7.6 KiB
Rust

//! Out-of-process plugin runner logic.
//!
//! This module is used by the host when it spawns itself in runner mode
//! (`--ocs-plugin-runner <socket> <cdylib>`). Keeping the runner code inside
//! `ocs_plugin_api` means the host only needs to know the CLI contract, not the
//! internal plugin-loading and IPC details.
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::Path;
use std::rc::Rc;
use crate::host::{BuiltinPlugin, InteractiveCommand};
use crate::ipc::client::{InteractiveRegistry, IpcClient, PluginHostApi};
use crate::ipc::protocol::{
HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, PLUGIN_TOKEN_ENV,
};
use crate::ipc::transport::{recv, send};
use crate::ribbon::owned::OwnedRibbonGroup;
/// Entry point for the plugin runner child process.
///
/// Connects back to the host on `socket_name`, loads the cdylib at
/// `cdylib_path`, and runs the request loop until the host sends `Shutdown`.
/// This function never returns normally; it exits the process on shutdown or
/// fatal error so the child does not fall through to the host's GUI main.
pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
eprintln!("[runner] starting for {cdylib_path:?} on {socket_name}");
let plugin = unsafe { load_plugin(cdylib_path)? };
let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new()));
let token = match std::env::var(PLUGIN_TOKEN_ENV) {
Ok(t) => t,
Err(_) => {
eprintln!("[runner] missing {PLUGIN_TOKEN_ENV}; exiting");
std::process::exit(1);
}
};
let client = IpcClient::connect(socket_name)?;
eprintln!("[runner] connected to host");
client.send_handshake(&token)?;
loop {
let msg: HostToPlugin = recv(&mut client.stream_ref())?;
eprintln!("[runner] host -> runner: {msg:?}");
match msg {
HostToPlugin::Request(req) => {
let resp = handle_host_request(&*plugin, &interactive, &client, req);
eprintln!("[runner] runner -> host: {resp:?}");
send(&mut client.stream_ref(), &PluginToHost::Response(resp))?;
}
HostToPlugin::Response(_) => {
// Responses are consumed by PluginHostApi::request synchronously.
// Reaching here means the host sent a response without a pending
// plugin request.
eprintln!("[runner] unexpected HostToPlugin::Response");
}
}
}
}
fn handle_host_request(
plugin: &dyn BuiltinPlugin,
interactive: &InteractiveRegistry,
client: &IpcClient,
req: HostRequest,
) -> HostResponse {
match req {
HostRequest::GetManifest => {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.manifest())) {
Ok(m) => HostResponse::Manifest(m.into()),
Err(_) => HostResponse::Error("plugin manifest() panicked".to_string()),
}
}
HostRequest::GetRibbon => {
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.ribbon())) {
Ok(groups) => HostResponse::Ribbon(
groups
.ribbon_groups()
.iter()
.map(OwnedRibbonGroup::from)
.collect(),
),
Err(_) => HostResponse::Error("plugin ribbon() panicked".to_string()),
}
}
HostRequest::Dispatch { cmd } => {
// The host supplies the active tab index as part of the dispatch
// context. We cache it inside PluginHostApi.
let mut proxy = PluginHostApi::new(client.clone(), 0, interactive.clone());
let handled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
plugin.dispatch(&mut proxy, &cmd)
}));
match handled {
Ok(b) => HostResponse::Bool(b),
Err(_) => HostResponse::Error("plugin dispatch panicked".to_string()),
}
}
HostRequest::InteractiveEvent { command_id, event } => {
let step = {
let mut registry = interactive.borrow_mut();
let Some(cmd) = registry.get_mut(&command_id) else {
return HostResponse::Error(format!(
"unknown interactive command {command_id}"
));
};
let cmd_ref: &mut dyn InteractiveCommand = cmd.as_mut();
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match event {
InteractiveEvent::Point(pt) => cmd_ref.on_point(pt),
InteractiveEvent::Enter => cmd_ref.on_enter(),
InteractiveEvent::ObjectPick { handle, pt } => {
cmd_ref.on_object_pick(handle, pt)
}
}))
};
match step {
Ok(s) => HostResponse::CommandStep(s),
Err(_) => HostResponse::Error("interactive command panicked".to_string()),
}
}
HostRequest::GetPrompt { command_id } => {
let result = {
let registry = interactive.borrow();
registry.get(&command_id).map(|cmd| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cmd.prompt()))
})
};
match result {
Some(Ok(s)) => HostResponse::Text(s),
Some(Err(_)) => HostResponse::Error("prompt() panicked".to_string()),
None => HostResponse::Error(format!("unknown interactive command {command_id}")),
}
}
HostRequest::NeedsEntityPick { command_id } => {
let result = {
let registry = interactive.borrow();
registry.get(&command_id).map(|cmd| {
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
cmd.needs_object_pick()
}))
})
};
match result {
Some(Ok(b)) => HostResponse::Bool(b),
Some(Err(_)) => HostResponse::Error("needs_object_pick() panicked".to_string()),
None => HostResponse::Error(format!("unknown interactive command {command_id}")),
}
}
HostRequest::Shutdown => {
// The runner will exit after this response is sent.
std::process::exit(0);
}
}
}
unsafe fn load_plugin(path: &Path) -> Result<Box<dyn BuiltinPlugin>, Box<dyn std::error::Error>> {
let lib = libloading::Library::new(path)?;
let version: libloading::Symbol<extern "C" fn() -> u32> = lib
.get(b"ocs_plugin_api_version")
.map_err(|_| "missing ocs_plugin_api_version symbol")?;
let v = version();
if !crate::host_accepts_plugin_version(v) {
return Err(format!(
"API version {v} is incompatible (host supports {}-{})",
crate::API_VERSION_MIN_SUPPORTED,
crate::API_VERSION
)
.into());
}
let register: libloading::Symbol<extern "C" fn() -> *mut Box<dyn BuiltinPlugin>> = lib
.get(b"ocs_plugin_register")
.map_err(|_| "missing ocs_plugin_register symbol")?;
let raw = register();
if raw.is_null() {
return Err("ocs_plugin_register returned null".into());
}
let plugin = *Box::from_raw(raw);
// Intentionally leak the library so its vtables remain valid for the
// lifetime of the process. The runner exits when the host disconnects.
let _ = std::mem::ManuallyDrop::new(lib);
Ok(plugin)
}