cad-editor/crates/ocs_plugin_api/src/process.rs

353 lines
12 KiB
Rust
Raw Normal View History

2026-06-23 22:54:33 +02:00
//! Process management for out-of-process plugins.
use std::path::{Path, PathBuf};
use std::process::{Child, Command};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use std::sync::Mutex;
use std::time::Duration;
use interprocess::local_socket::traits::Listener;
use interprocess::local_socket::{GenericNamespaced, ListenerOptions, Stream, ToNsName};
use crate::host::{CommandStep, HostApi};
use crate::ipc::protocol::{
HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost,
};
use crate::ipc::server::handle_plugin_request;
use crate::ipc::transport::{recv, send};
use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup as OwnedRibbonGroupAlias};
2026-06-24 10:27:07 +02:00
use serde::de::DeserializeOwned;
mod manager;
pub use manager::{DispatchResult, PluginManager};
2026-06-23 22:54:33 +02:00
/// Maximum time to wait for the plugin runner to connect back to the host.
const SPAWN_TIMEOUT: Duration = Duration::from_secs(10);
fn spawn_timeout() -> Duration {
std::env::var("OCS_PLUGIN_SPAWN_TIMEOUT_SECS")
.ok()
.and_then(|s| s.parse().ok())
.map(Duration::from_secs)
.unwrap_or(SPAWN_TIMEOUT)
}
#[derive(Debug, thiserror::Error)]
pub enum PluginError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("transport error: {0}")]
Transport(#[from] crate::ipc::transport::TransportError),
#[error("plugin runner error: {0}")]
Runner(String),
#[error("spawn timeout: runner did not connect within {0:?}")]
SpawnTimeout(Duration),
#[error("runner exited before connecting")]
RunnerExited,
#[error("unexpected response: {0:?}")]
UnexpectedResponse(HostResponse),
}
/// One spawned plugin process.
pub struct PluginProcess {
2026-06-24 10:27:07 +02:00
stream: Mutex<Option<Stream>>,
child: Mutex<Option<Child>>,
2026-06-23 22:54:33 +02:00
id: String,
manifest: OwnedPluginManifest,
ribbon: Vec<OwnedRibbonGroupAlias>,
}
impl PluginProcess {
/// Spawn the plugin cdylib in a separate process and connect to it.
pub fn spawn(
cdylib_path: &Path,
host: &mut dyn HostApi,
) -> Result<Self, PluginError> {
let socket_name = generate_socket_name();
let socket_name_ref: interprocess::local_socket::Name = socket_name
.clone()
.to_ns_name::<GenericNamespaced>()
.expect("valid namespaced name");
let runner_path = runner_executable()?;
eprintln!("[plugin] spawning runner {} for {}", runner_path.display(), cdylib_path.display());
// Create the listener before spawning so the runner can connect immediately.
let listener = ListenerOptions::new()
.name(socket_name_ref)
.create_sync()?;
let mut child = Command::new(&runner_path)
.arg("--ocs-plugin-runner")
.arg(&socket_name)
.arg(cdylib_path)
.spawn()?;
// Accept the runner connection with a timeout so a hung/crashed runner
// does not block the host indefinitely.
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(listener.accept());
});
let stream = match rx.recv_timeout(spawn_timeout()) {
Ok(Ok(stream)) => {
eprintln!("[plugin] runner connected");
2026-06-24 10:27:07 +02:00
Mutex::new(Some(stream))
2026-06-23 22:54:33 +02:00
}
Ok(Err(e)) => return Err(e.into()),
Err(mpsc::RecvTimeoutError::Timeout) => {
let _ = child.kill();
return Err(PluginError::SpawnTimeout(spawn_timeout()));
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
let _ = child.kill();
return Err(PluginError::RunnerExited);
}
};
// The runner first answers GetManifest and GetRibbon so the host can
// build the UI without keeping the plugin object alive.
let no_op = &mut |_| {};
let manifest = match call(&stream, host, HostRequest::GetManifest, no_op)? {
HostResponse::Manifest(m) => m,
other => return Err(PluginError::UnexpectedResponse(other)),
};
let ribbon = match call(&stream, host, HostRequest::GetRibbon, no_op)? {
HostResponse::Ribbon(r) => r,
other => return Err(PluginError::UnexpectedResponse(other)),
};
let id = manifest.id.clone();
Ok(Self {
stream,
2026-06-24 10:27:07 +02:00
child: Mutex::new(Some(child)),
2026-06-23 22:54:33 +02:00
id,
manifest,
ribbon,
})
}
pub fn id(&self) -> &str {
&self.id
}
pub fn manifest(&self) -> &OwnedPluginManifest {
&self.manifest
}
pub fn ribbon(&self) -> &[OwnedRibbonGroupAlias] {
&self.ribbon
}
pub fn dispatch(
&self,
host: &mut dyn HostApi,
cmd: &str,
on_start_interactive: &mut dyn FnMut(u64),
) -> Result<bool, PluginError> {
eprintln!("[plugin] dispatching {cmd}");
let result = match call(
&self.stream,
host,
HostRequest::Dispatch {
cmd: cmd.to_string(),
},
on_start_interactive,
)? {
HostResponse::Bool(b) => Ok(b),
other => Err(PluginError::UnexpectedResponse(other)),
};
eprintln!("[plugin] dispatch {cmd} result: {result:?}");
result
}
/// Send an interactive event for `command_id` and return the step the
/// plugin command produces. Interactive events are not expected to trigger
/// nested host API calls, so this path does not supply a `HostApi`.
pub fn interactive_event(
&self,
command_id: u64,
event: InteractiveEvent,
) -> Result<CommandStep, PluginError> {
2026-06-24 10:27:07 +02:00
self.send_request(HostRequest::InteractiveEvent { command_id, event })?;
2026-06-23 22:54:33 +02:00
loop {
2026-06-24 10:27:07 +02:00
match self.recv_response::<PluginToHost>()? {
2026-06-23 22:54:33 +02:00
PluginToHost::Response(HostResponse::CommandStep(s)) => return Ok(s),
PluginToHost::Response(other) => {
return Err(PluginError::UnexpectedResponse(other))
}
PluginToHost::Request(req) => {
let resp = crate::ipc::protocol::PluginResponse::Error(format!(
"unexpected nested request during interactive event: {req:?}"
));
2026-06-24 10:27:07 +02:00
self.send_response(resp)?;
2026-06-23 22:54:33 +02:00
}
}
}
}
/// Ask the plugin process for the current prompt of an interactive command.
pub fn get_prompt(&self, command_id: u64) -> Result<String, PluginError> {
2026-06-24 10:27:07 +02:00
self.send_request(HostRequest::GetPrompt { command_id })?;
2026-06-23 22:54:33 +02:00
loop {
2026-06-24 10:27:07 +02:00
match self.recv_response::<PluginToHost>()? {
2026-06-23 22:54:33 +02:00
PluginToHost::Response(HostResponse::Text(s)) => return Ok(s),
PluginToHost::Response(other) => {
return Err(PluginError::UnexpectedResponse(other))
}
PluginToHost::Request(req) => {
let resp = crate::ipc::protocol::PluginResponse::Error(format!(
"unexpected nested request during get_prompt: {req:?}"
));
2026-06-24 10:27:07 +02:00
self.send_response(resp)?;
2026-06-23 22:54:33 +02:00
}
}
}
}
/// Ask the plugin process whether an interactive command wants object picks.
pub fn needs_entity_pick(&self, command_id: u64) -> Result<bool, PluginError> {
2026-06-24 10:27:07 +02:00
self.send_request(HostRequest::NeedsEntityPick { command_id })?;
2026-06-23 22:54:33 +02:00
loop {
2026-06-24 10:27:07 +02:00
match self.recv_response::<PluginToHost>()? {
2026-06-23 22:54:33 +02:00
PluginToHost::Response(HostResponse::Bool(b)) => return Ok(b),
PluginToHost::Response(other) => {
return Err(PluginError::UnexpectedResponse(other))
}
PluginToHost::Request(req) => {
let resp = crate::ipc::protocol::PluginResponse::Error(format!(
"unexpected nested request during needs_entity_pick: {req:?}"
));
2026-06-24 10:27:07 +02:00
self.send_response(resp)?;
2026-06-23 22:54:33 +02:00
}
}
}
}
pub fn is_alive(&self) -> bool {
2026-06-24 10:27:07 +02:00
let mut guard = self.child.lock().unwrap_or_else(|e| e.into_inner());
match guard.as_mut() {
Some(child) => match child.try_wait() {
Ok(None) => true,
Ok(Some(_)) | Err(_) => false,
},
None => false,
2026-06-23 22:54:33 +02:00
}
}
2026-06-24 10:27:07 +02:00
/// Tear down the plugin process without blocking the caller. The stream is
/// closed and the child is killed and reaped in a detached background thread.
pub fn shutdown(&self) {
let stream = self.stream.lock().unwrap_or_else(|e| e.into_inner()).take();
let child = self.child.lock().unwrap_or_else(|e| e.into_inner()).take();
std::thread::spawn(move || {
drop(stream);
if let Some(mut child) = child {
let _ = child.kill();
let _ = child.wait();
}
});
2026-06-23 22:54:33 +02:00
}
}
impl Drop for PluginProcess {
fn drop(&mut self) {
2026-06-24 10:27:07 +02:00
self.shutdown();
}
}
impl PluginProcess {
fn send_request(&self, req: HostRequest) -> Result<(), PluginError> {
let mut guard = self.stream.lock().unwrap_or_else(|e| e.into_inner());
let stream = guard.as_mut().ok_or_else(shutdown_error)?;
send(stream, &HostToPlugin::Request(req)).map_err(Into::into)
2026-06-23 22:54:33 +02:00
}
2026-06-24 10:27:07 +02:00
fn send_response(&self, resp: crate::ipc::protocol::PluginResponse) -> Result<(), PluginError> {
let mut guard = self.stream.lock().unwrap_or_else(|e| e.into_inner());
let stream = guard.as_mut().ok_or_else(shutdown_error)?;
send(stream, &HostToPlugin::Response(resp)).map_err(Into::into)
}
fn recv_response<T: DeserializeOwned>(&self) -> Result<T, PluginError> {
let mut guard = self.stream.lock().unwrap_or_else(|e| e.into_inner());
let stream = guard.as_mut().ok_or_else(shutdown_error)?;
recv(stream).map_err(Into::into)
}
}
fn shutdown_error() -> PluginError {
PluginError::Io(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"plugin process has been shut down",
))
2026-06-23 22:54:33 +02:00
}
/// Send a host request and wait for the response, handling any nested plugin
/// requests inline using the supplied `HostApi`.
fn call(
2026-06-24 10:27:07 +02:00
stream: &Mutex<Option<Stream>>,
2026-06-23 22:54:33 +02:00
host: &mut dyn HostApi,
req: HostRequest,
on_start_interactive: &mut dyn FnMut(u64),
) -> Result<HostResponse, PluginError> {
eprintln!("[plugin] host -> runner: {req:?}");
2026-06-24 10:27:07 +02:00
{
let mut guard = stream.lock().unwrap_or_else(|e| e.into_inner());
let stream = guard.as_mut().ok_or_else(shutdown_error)?;
send(stream, &HostToPlugin::Request(req))?;
}
2026-06-23 22:54:33 +02:00
loop {
2026-06-24 10:27:07 +02:00
let msg = {
let mut guard = stream.lock().unwrap_or_else(|e| e.into_inner());
let stream = guard.as_mut().ok_or_else(shutdown_error)?;
recv::<PluginToHost>(stream)?
};
2026-06-23 22:54:33 +02:00
eprintln!("[plugin] runner -> host: {msg:?}");
match msg {
PluginToHost::Response(resp) => return Ok(resp),
PluginToHost::Request(plugin_req) => {
let resp = handle_plugin_request(host, plugin_req, on_start_interactive);
eprintln!("[plugin] host -> runner response: {resp:?}");
2026-06-24 10:27:07 +02:00
let mut guard = stream.lock().unwrap_or_else(|e| e.into_inner());
let stream = guard.as_mut().ok_or_else(shutdown_error)?;
send(stream, &HostToPlugin::Response(resp))?;
2026-06-23 22:54:33 +02:00
}
}
}
}
/// Locate the executable to spawn for running a plugin.
///
/// The host spawns *itself* in runner mode (`--ocs-plugin-runner`), so the
/// runner is always available and stays in sync with the host binary. This
/// avoids shipping a separate `ocs_plugin_runner` binary and works the same on
/// Windows, macOS, and Linux.
///
/// For testing or unusual deployment layouts, set `OCS_PLUGIN_RUNNER_EXE` to
/// the host executable path.
fn runner_executable() -> Result<PathBuf, PluginError> {
if let Ok(path) = std::env::var("OCS_PLUGIN_RUNNER_EXE") {
let path = PathBuf::from(path);
if path.exists() {
return Ok(path);
}
}
let path = std::env::current_exe()?;
if path.exists() {
Ok(path)
} else {
Err(PluginError::Runner(format!(
"cannot find current executable at {}",
path.display()
)))
}
}
/// Generate a unique local socket name.
fn generate_socket_name() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("ocs_plugin_{}_{}", std::process::id(), n)
}