forked from 1b-agent/opencadstudio-upstream
Initial commit
This commit is contained in:
1 parent
125b89d8da
commit
dcfaed0182
26 files changed
+2080
-143
No files matched your search
@@ -0,0 +1,315 @@
|
||||
//! 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};
|
||||
|
||||
/// 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 {
|
||||
stream: Mutex<Stream>,
|
||||
child: Mutex<Child>,
|
||||
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");
|
||||
Mutex::new(stream)
|
||||
}
|
||||
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,
|
||||
child: Mutex::new(child),
|
||||
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> {
|
||||
send(
|
||||
&mut self.stream.lock().unwrap(),
|
||||
&HostToPlugin::Request(HostRequest::InteractiveEvent { command_id, event }),
|
||||
)?;
|
||||
loop {
|
||||
match recv::<PluginToHost>(&mut self.stream.lock().unwrap())? {
|
||||
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:?}"
|
||||
));
|
||||
send(&mut self.stream.lock().unwrap(), &HostToPlugin::Response(resp))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the plugin process for the current prompt of an interactive command.
|
||||
pub fn get_prompt(&self, command_id: u64) -> Result<String, PluginError> {
|
||||
send(
|
||||
&mut self.stream.lock().unwrap(),
|
||||
&HostToPlugin::Request(HostRequest::GetPrompt { command_id }),
|
||||
)?;
|
||||
loop {
|
||||
match recv::<PluginToHost>(&mut self.stream.lock().unwrap())? {
|
||||
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:?}"
|
||||
));
|
||||
send(&mut self.stream.lock().unwrap(), &HostToPlugin::Response(resp))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask the plugin process whether an interactive command wants object picks.
|
||||
pub fn needs_entity_pick(&self, command_id: u64) -> Result<bool, PluginError> {
|
||||
send(
|
||||
&mut self.stream.lock().unwrap(),
|
||||
&HostToPlugin::Request(HostRequest::NeedsEntityPick { command_id }),
|
||||
)?;
|
||||
loop {
|
||||
match recv::<PluginToHost>(&mut self.stream.lock().unwrap())? {
|
||||
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:?}"
|
||||
));
|
||||
send(&mut self.stream.lock().unwrap(), &HostToPlugin::Response(resp))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_alive(&self) -> bool {
|
||||
match self.child.lock().unwrap().try_wait() {
|
||||
Ok(None) => true,
|
||||
Ok(Some(_)) | Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kill(&self) -> std::io::Result<()> {
|
||||
let _ = call_no_host(&self.stream, HostRequest::Shutdown);
|
||||
self.child.lock().unwrap().kill()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for PluginProcess {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.kill();
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a host request and wait for the response, handling any nested plugin
|
||||
/// requests inline using the supplied `HostApi`.
|
||||
fn call(
|
||||
stream: &Mutex<Stream>,
|
||||
host: &mut dyn HostApi,
|
||||
req: HostRequest,
|
||||
on_start_interactive: &mut dyn FnMut(u64),
|
||||
) -> Result<HostResponse, PluginError> {
|
||||
eprintln!("[plugin] host -> runner: {req:?}");
|
||||
send(&mut stream.lock().unwrap(), &HostToPlugin::Request(req))?;
|
||||
loop {
|
||||
let msg = recv::<PluginToHost>(&mut stream.lock().unwrap())?;
|
||||
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:?}");
|
||||
send(&mut stream.lock().unwrap(), &HostToPlugin::Response(resp))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort shutdown request that does not wait for a response.
|
||||
fn call_no_host(
|
||||
stream: &Mutex<Stream>,
|
||||
req: HostRequest,
|
||||
) -> Result<(), crate::ipc::transport::TransportError> {
|
||||
send(&mut stream.lock().unwrap(), &HostToPlugin::Request(req))
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
Reference in new issue
Block a user