forked from 1b-agent/opencadstudio-upstream
Snapshot: V4 plugin API and host integration
This commit is contained in:
1 parent
448869ac35
commit
29c41a4041
36 files changed
+5463
-253
No files matched your search
@@ -13,12 +13,253 @@
|
||||
|
||||
use std::any::Any;
|
||||
|
||||
use acadrust::xdata::ExtendedDataRecord;
|
||||
use acadrust::{CadDocument, EntityType, Handle};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::ribbon::CadModule;
|
||||
|
||||
// Re-export the acadrust crate and the types that appear in the HostApi trait
|
||||
// so out-of-tree plugins can use them without adding their own acadrust
|
||||
// dependency (which would risk an ABI-mismatching version).
|
||||
pub use acadrust;
|
||||
pub use acadrust::{CadDocument, EntityType, Handle};
|
||||
pub use acadrust::xdata::ExtendedDataRecord;
|
||||
|
||||
use crate::ipc::protocol::{PluginRequest, PluginResponse};
|
||||
|
||||
/// Thread-safe handle that can issue host requests from plugin worker threads.
|
||||
/// Out-of-process V4 plugins implement this; in-process hosts may return `None`.
|
||||
pub trait PluginRequestSender: Send + Sync {
|
||||
fn request(&self, req: PluginRequest) -> Result<PluginResponse, PluginRequestError>;
|
||||
}
|
||||
|
||||
/// Error returned when a plugin worker thread cannot issue a host request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginRequestError(pub String);
|
||||
|
||||
impl std::fmt::Display for PluginRequestError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "PluginRequestError: {}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PluginRequestError {}
|
||||
|
||||
/// Log level carried by [`PluginNotification::Log`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum LogLevel {
|
||||
Debug,
|
||||
Info,
|
||||
Warn,
|
||||
Error,
|
||||
}
|
||||
|
||||
/// A notification the host sends to a plugin. These are best-effort,
|
||||
/// full-duplex messages correlated with an optional `command_id`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum HostNotification {
|
||||
InputLine { line: String },
|
||||
Cancel,
|
||||
DocumentChanged { version: u64 },
|
||||
SelectionChanged { handles: Vec<Handle> },
|
||||
Raw(Vec<u8>),
|
||||
/// V4 snapshot changed for a specific tab. Discriminant 5.
|
||||
DocumentChangedV4 { tab_id: u64, version: u64 },
|
||||
/// V4 tab closed notification. Discriminant 6.
|
||||
DocumentTabClosed { tab_id: u64 },
|
||||
/// Fallback for notification variants added in future minor revisions.
|
||||
/// Carries the raw bincode payload so an older peer can ignore it without
|
||||
/// failing deserialization.
|
||||
Unknown(Vec<u8>),
|
||||
}
|
||||
|
||||
impl Serialize for HostNotification {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut bytes = Vec::new();
|
||||
match self {
|
||||
HostNotification::InputLine { line } => {
|
||||
bytes.push(0);
|
||||
bincode::serialize_into(&mut bytes, line)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
HostNotification::Cancel => bytes.push(1),
|
||||
HostNotification::DocumentChanged { version } => {
|
||||
bytes.push(2);
|
||||
bincode::serialize_into(&mut bytes, version)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
HostNotification::SelectionChanged { handles } => {
|
||||
bytes.push(3);
|
||||
bincode::serialize_into(&mut bytes, handles)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
HostNotification::Raw(data) => {
|
||||
bytes.push(4);
|
||||
bincode::serialize_into(&mut bytes, data)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
HostNotification::DocumentChangedV4 { tab_id, version } => {
|
||||
bytes.push(5);
|
||||
bincode::serialize_into(&mut bytes, tab_id)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
bincode::serialize_into(&mut bytes, version)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
HostNotification::DocumentTabClosed { tab_id } => {
|
||||
bytes.push(6);
|
||||
bincode::serialize_into(&mut bytes, tab_id)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
HostNotification::Unknown(raw) => bytes.extend_from_slice(raw),
|
||||
}
|
||||
bytes.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for HostNotification {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let bytes = Vec::<u8>::deserialize(deserializer)?;
|
||||
if bytes.is_empty() {
|
||||
return Err(serde::de::Error::custom("empty HostNotification"));
|
||||
}
|
||||
let discriminant = bytes[0];
|
||||
let rest = &bytes[1..];
|
||||
match discriminant {
|
||||
0 => bincode::deserialize(rest)
|
||||
.map(|line| HostNotification::InputLine { line })
|
||||
.map_err(serde::de::Error::custom),
|
||||
1 => Ok(HostNotification::Cancel),
|
||||
2 => bincode::deserialize(rest)
|
||||
.map(|version| HostNotification::DocumentChanged { version })
|
||||
.map_err(serde::de::Error::custom),
|
||||
3 => bincode::deserialize(rest)
|
||||
.map(|handles| HostNotification::SelectionChanged { handles })
|
||||
.map_err(serde::de::Error::custom),
|
||||
4 => bincode::deserialize(rest)
|
||||
.map(HostNotification::Raw)
|
||||
.map_err(serde::de::Error::custom),
|
||||
5 => bincode::deserialize(rest)
|
||||
.map(|(tab_id, version)| HostNotification::DocumentChangedV4 { tab_id, version })
|
||||
.map_err(serde::de::Error::custom),
|
||||
6 => bincode::deserialize(rest)
|
||||
.map(|tab_id| HostNotification::DocumentTabClosed { tab_id })
|
||||
.map_err(serde::de::Error::custom),
|
||||
_ => Ok(HostNotification::Unknown(bytes)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A notification a plugin sends to the host. These are best-effort,
|
||||
/// full-duplex messages correlated with an optional `command_id`.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
#[non_exhaustive]
|
||||
pub enum PluginNotification {
|
||||
Output { text: String },
|
||||
Error { text: String },
|
||||
Prompt { text: String },
|
||||
Progress { percent: u8 },
|
||||
Log { level: LogLevel, text: String },
|
||||
Raw(Vec<u8>),
|
||||
/// V4 REPL status update. Discriminant 6.
|
||||
ReplStatus { status: String, message: String },
|
||||
/// Fallback for notification variants added in future minor revisions.
|
||||
/// Carries the raw bincode payload so an older peer can ignore it without
|
||||
/// failing deserialization.
|
||||
Unknown(Vec<u8>),
|
||||
}
|
||||
|
||||
impl Serialize for PluginNotification {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
let mut bytes = Vec::new();
|
||||
match self {
|
||||
PluginNotification::Output { text } => {
|
||||
bytes.push(0);
|
||||
bincode::serialize_into(&mut bytes, text)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
PluginNotification::Error { text } => {
|
||||
bytes.push(1);
|
||||
bincode::serialize_into(&mut bytes, text)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
PluginNotification::Prompt { text } => {
|
||||
bytes.push(2);
|
||||
bincode::serialize_into(&mut bytes, text)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
PluginNotification::Progress { percent } => {
|
||||
bytes.push(3);
|
||||
bincode::serialize_into(&mut bytes, percent)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
PluginNotification::Log { level, text } => {
|
||||
bytes.push(4);
|
||||
bincode::serialize_into(&mut bytes, level)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
bincode::serialize_into(&mut bytes, text)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
PluginNotification::Raw(data) => {
|
||||
bytes.push(5);
|
||||
bincode::serialize_into(&mut bytes, data)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
PluginNotification::ReplStatus { status, message } => {
|
||||
bytes.push(6);
|
||||
bincode::serialize_into(&mut bytes, status)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
bincode::serialize_into(&mut bytes, message)
|
||||
.map_err(serde::ser::Error::custom)?;
|
||||
}
|
||||
PluginNotification::Unknown(raw) => bytes.extend_from_slice(raw),
|
||||
}
|
||||
bytes.serialize(serializer)
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for PluginNotification {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let bytes = Vec::<u8>::deserialize(deserializer)?;
|
||||
if bytes.is_empty() {
|
||||
return Err(serde::de::Error::custom("empty PluginNotification"));
|
||||
}
|
||||
let discriminant = bytes[0];
|
||||
let rest = &bytes[1..];
|
||||
match discriminant {
|
||||
0 => bincode::deserialize(rest)
|
||||
.map(|text| PluginNotification::Output { text })
|
||||
.map_err(serde::de::Error::custom),
|
||||
1 => bincode::deserialize(rest)
|
||||
.map(|text| PluginNotification::Error { text })
|
||||
.map_err(serde::de::Error::custom),
|
||||
2 => bincode::deserialize(rest)
|
||||
.map(|text| PluginNotification::Prompt { text })
|
||||
.map_err(serde::de::Error::custom),
|
||||
3 => bincode::deserialize(rest)
|
||||
.map(|percent| PluginNotification::Progress { percent })
|
||||
.map_err(serde::de::Error::custom),
|
||||
4 => {
|
||||
let (level, text): (LogLevel, String) = bincode::deserialize(rest)
|
||||
.map_err(serde::de::Error::custom)?;
|
||||
Ok(PluginNotification::Log { level, text })
|
||||
}
|
||||
5 => bincode::deserialize(rest)
|
||||
.map(PluginNotification::Raw)
|
||||
.map_err(serde::de::Error::custom),
|
||||
6 => bincode::deserialize(rest)
|
||||
.map(|(status, message)| PluginNotification::ReplStatus { status, message })
|
||||
.map_err(serde::de::Error::custom),
|
||||
_ => Ok(PluginNotification::Unknown(bytes)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An add-on package's entry point: its manifest, optional ribbon tab, and
|
||||
/// command dispatch. Built-in (in-tree) and dynamically-loaded (cdylib) plugins
|
||||
/// implement the same trait from this crate, so an out-of-tree add-on targets
|
||||
/// the stable contract rather than the host binary.
|
||||
/// An add-on package's entry point: its manifest, optional ribbon tab, and
|
||||
/// command dispatch. Built-in (in-tree) and dynamically-loaded (cdylib) plugins
|
||||
/// implement the same trait from this crate, so an out-of-tree add-on targets
|
||||
@@ -27,6 +268,28 @@ pub trait BuiltinPlugin: Send + Sync {
|
||||
fn manifest(&self) -> &'static PluginManifest;
|
||||
fn ribbon(&self) -> Box<dyn CadModule>;
|
||||
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
|
||||
|
||||
/// Long-running REPL code execution (API v4). The plugin may start the
|
||||
/// work on another thread and call `respond` when finished; the runner
|
||||
/// will forward the result to the host. The supplied `host` is the active
|
||||
/// document tab's API surface, so the REPL session is tied to a tab.
|
||||
///
|
||||
/// Returning `false` means the plugin does not support code execution.
|
||||
fn start_execute_code(
|
||||
&mut self,
|
||||
_host: &mut dyn HostApi,
|
||||
_command_id: u64,
|
||||
_code: &str,
|
||||
_source: CommandSource,
|
||||
_respond: Box<dyn FnOnce(ExecutionResult) + Send>,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// State-only callback for host-to-plugin notifications. Added in API v4;
|
||||
/// the V4 runner guarantees it is only called for plugins that report
|
||||
/// API major 4 or newer.
|
||||
fn on_notification(&mut self, _command_id: Option<u64>, _notification: HostNotification) {}
|
||||
}
|
||||
|
||||
/// A point-driven interactive command a plugin starts via
|
||||
@@ -132,6 +395,12 @@ pub trait HostApi {
|
||||
|
||||
/// Add an entity to the active document, returning its handle.
|
||||
fn add_entity(&mut self, entity: EntityType) -> Handle;
|
||||
/// Add multiple entities to the active document, returning their handles.
|
||||
/// The default implementation calls [`add_entity`](Self::add_entity) for each
|
||||
/// entity; hosts should override it for batch efficiency.
|
||||
fn add_entities(&mut self, entities: Vec<EntityType>) -> Vec<Handle> {
|
||||
entities.into_iter().map(|e| self.add_entity(e)).collect()
|
||||
}
|
||||
/// Replace the existing entity that carries `entity`'s handle, preserving
|
||||
/// its identity (handle and owning block). Returns `false` when no entity
|
||||
/// has that handle. This is the sanctioned way to commit in-place edits
|
||||
@@ -203,6 +472,53 @@ pub trait HostApi {
|
||||
fn document_view(&mut self) -> Option<crate::shm::DocumentViewInfo> {
|
||||
None
|
||||
}
|
||||
|
||||
// ── Notifications (added in API v4; appended at the end to keep vtable
|
||||
// indices stable for V2/V3 plugins) ───────────────────────────────────────
|
||||
|
||||
/// Send a best-effort notification from the plugin to the host.
|
||||
///
|
||||
/// In-process plugins can override this to forward to the host event loop;
|
||||
/// out-of-process plugins send a V4 notification frame.
|
||||
fn notify_plugin(&mut self, _command_id: Option<u64>, _notification: PluginNotification) {}
|
||||
|
||||
/// Poll for a host-to-plugin notification, if any.
|
||||
///
|
||||
/// Returns the optional `command_id` used to correlate the notification
|
||||
/// with a running command, and the notification payload. Long-running
|
||||
/// plugins should call this periodically to drain the bounded queue.
|
||||
fn try_recv_notification(
|
||||
&mut self,
|
||||
) -> Option<(Option<u64>, HostNotification)> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Returns a thread-safe handle that can issue host requests from worker
|
||||
/// threads. Out-of-process V4 plugins implement this; in-process hosts may
|
||||
/// return `None`.
|
||||
fn plugin_request_sender(&self) -> Option<Box<dyn PluginRequestSender>> {
|
||||
None
|
||||
}
|
||||
|
||||
// ── V4 tab/document identity (added for REPL; appended at the end) ───────
|
||||
|
||||
/// Stable tab identifier for the active document tab.
|
||||
fn tab_id(&self) -> u64 {
|
||||
self.tab_index() as u64
|
||||
}
|
||||
|
||||
/// Open (or refresh) the host-side V4 shared document view for `tab_id`
|
||||
/// and return the mapping information. In-process hosts implement this;
|
||||
/// out-of-process plugin proxies return `None`.
|
||||
fn document_view_v4(&mut self, tab_id: u64) -> Option<crate::shm::DocumentViewInfo> {
|
||||
let _ = tab_id;
|
||||
None
|
||||
}
|
||||
|
||||
/// Close the host-side V4 shared document view for `tab_id`.
|
||||
fn close_document_view_v4(&mut self, tab_id: u64) {
|
||||
let _ = tab_id;
|
||||
}
|
||||
}
|
||||
|
||||
/// Simplified, read-only entity kind exposed by [`DocumentReader`].
|
||||
@@ -349,3 +665,95 @@ pub fn ensure_plugin_state<'a, T: Any + Send + Sync>(
|
||||
any.downcast_mut::<T>()
|
||||
.expect("plugin state type mismatch for plugin_id")
|
||||
}
|
||||
|
||||
#[cfg(feature = "host")]
|
||||
mod repl {
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Source surface that submitted a REPL code snippet.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum CommandSource {
|
||||
CommandLine,
|
||||
Script,
|
||||
Editor,
|
||||
}
|
||||
|
||||
/// Outcome of a REPL `ExecuteCode` request.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub struct ExecutionResult {
|
||||
pub success: bool,
|
||||
pub output: Option<String>,
|
||||
pub error: Option<String>,
|
||||
pub error_type: Option<String>,
|
||||
pub traceback: Option<String>,
|
||||
pub line_number: Option<u32>,
|
||||
pub column_number: Option<u32>,
|
||||
pub duration_ms: f64,
|
||||
}
|
||||
|
||||
impl ExecutionResult {
|
||||
/// Create a new execution result.
|
||||
pub fn new(
|
||||
success: bool,
|
||||
output: Option<String>,
|
||||
error: Option<String>,
|
||||
error_type: Option<String>,
|
||||
traceback: Option<String>,
|
||||
line_number: Option<u32>,
|
||||
column_number: Option<u32>,
|
||||
duration_ms: f64,
|
||||
) -> Self {
|
||||
Self {
|
||||
success,
|
||||
output,
|
||||
error,
|
||||
error_type,
|
||||
traceback,
|
||||
line_number,
|
||||
column_number,
|
||||
duration_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawn a thread that runs `f` and calls `respond` with the returned
|
||||
/// `ExecutionResult`. If `f` panics, `respond` is called with a panic error
|
||||
/// result instead, so the host never waits indefinitely for a callback that
|
||||
/// will never arrive.
|
||||
pub fn execute_code_guard<F>(
|
||||
respond: Box<dyn FnOnce(ExecutionResult) + Send>,
|
||||
f: F,
|
||||
) -> std::thread::JoinHandle<()>
|
||||
where
|
||||
F: FnOnce() -> ExecutionResult + Send + 'static,
|
||||
{
|
||||
std::thread::spawn(move || {
|
||||
let result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) {
|
||||
Ok(result) => result,
|
||||
Err(payload) => {
|
||||
let msg = payload
|
||||
.downcast_ref::<&str>()
|
||||
.copied()
|
||||
.or_else(|| payload.downcast_ref::<String>().map(|s| s.as_str()))
|
||||
.unwrap_or("execute_code panicked");
|
||||
ExecutionResult {
|
||||
success: false,
|
||||
output: None,
|
||||
error: Some(format!("panic: {}", msg)),
|
||||
error_type: Some("panic".to_string()),
|
||||
traceback: None,
|
||||
line_number: None,
|
||||
column_number: None,
|
||||
duration_ms: 0.0,
|
||||
}
|
||||
}
|
||||
};
|
||||
respond(result);
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "host")]
|
||||
pub use repl::{execute_code_guard, CommandSource, ExecutionResult};
|
||||
@@ -0,0 +1,91 @@
|
||||
//! Host-side V4 snapshot manager.
|
||||
//!
|
||||
//! Maintains a per-tab shared-memory document view keyed by the stable
|
||||
//! `DocumentTab.id`. The manager is used by `src/plugin/v4_support.rs` to
|
||||
//! open, publish, and close V4 snapshots independently of the V3 reader path.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
use acadrust::CadDocument;
|
||||
|
||||
use crate::shm::{DocumentSnapshotStore, DocumentViewDataV4, DocumentViewInfo};
|
||||
|
||||
static MANAGER: OnceLock<Mutex<HostV4SnapshotManager>> = OnceLock::new();
|
||||
|
||||
fn global_manager() -> &'static Mutex<HostV4SnapshotManager> {
|
||||
MANAGER.get_or_init(|| Mutex::new(HostV4SnapshotManager::new()))
|
||||
}
|
||||
|
||||
/// Per-tab V4 shared-memory snapshot manager.
|
||||
pub struct HostV4SnapshotManager {
|
||||
stores: HashMap<u64, DocumentSnapshotStore<DocumentViewDataV4>>,
|
||||
segment_size: usize,
|
||||
}
|
||||
|
||||
impl HostV4SnapshotManager {
|
||||
/// Create a new manager with the default segment size.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
stores: HashMap::new(),
|
||||
segment_size: default_segment_size(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Open (or refresh) the V4 snapshot for `tab_id` and return the view info.
|
||||
pub fn open(&mut self, tab_id: u64, doc: &CadDocument) -> Option<DocumentViewInfo> {
|
||||
let store = match self.stores.get_mut(&tab_id) {
|
||||
Some(store) => store,
|
||||
None => {
|
||||
let store = DocumentSnapshotStore::new(tab_id, self.segment_size).ok()?;
|
||||
self.stores.entry(tab_id).or_insert(store)
|
||||
}
|
||||
};
|
||||
if let Err(e) = store.publish(&doc.into()) {
|
||||
eprintln!("[host] failed to publish V4 document view for tab {tab_id}: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(DocumentViewInfo {
|
||||
path: store.path().to_string_lossy().into_owned(),
|
||||
version: store.version(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish the latest document to the existing V4 snapshot for `tab_id`.
|
||||
/// Returns the updated view info if the snapshot exists.
|
||||
pub fn publish(&mut self, tab_id: u64, doc: &CadDocument) -> Option<DocumentViewInfo> {
|
||||
let store = self.stores.get_mut(&tab_id)?;
|
||||
if let Err(e) = store.publish(&doc.into()) {
|
||||
eprintln!("[host] failed to publish V4 document view for tab {tab_id}: {e}");
|
||||
return None;
|
||||
}
|
||||
Some(DocumentViewInfo {
|
||||
path: store.path().to_string_lossy().into_owned(),
|
||||
version: store.version(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Close the V4 snapshot for `tab_id`.
|
||||
pub fn close(&mut self, tab_id: u64) {
|
||||
self.stores.remove(&tab_id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HostV4SnapshotManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Global accessor for the host V4 snapshot manager.
|
||||
pub fn manager() -> &'static Mutex<HostV4SnapshotManager> {
|
||||
global_manager()
|
||||
}
|
||||
|
||||
fn default_segment_size() -> usize {
|
||||
std::env::var("OCS_V4_SNAPSHOT_SEGMENT_SIZE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(16 * 1024 * 1024)
|
||||
.max(1024 * 1024)
|
||||
}
|
||||
@@ -16,7 +16,7 @@ use crate::ipc::protocol::{
|
||||
HostResponse, HostToPlugin, PluginRequest, PluginResponse, PluginToHost, RunnerHandshake,
|
||||
};
|
||||
use crate::ipc::transport::{recv, send};
|
||||
use crate::shm::{DocumentViewInfo, SharedDocumentReader};
|
||||
use crate::shm::{DocumentViewInfo, SharedDocumentReader, DocumentViewData};
|
||||
|
||||
/// Shared registry of active interactive commands, keyed by host-assigned id.
|
||||
pub type InteractiveRegistry = Rc<RefCell<HashMap<u64, Box<dyn InteractiveCommand>>>>;
|
||||
@@ -60,10 +60,10 @@ impl IpcClient {
|
||||
&self,
|
||||
req: PluginRequest,
|
||||
) -> Result<PluginResponse, crate::ipc::transport::TransportError> {
|
||||
send(&mut self.stream.borrow_mut(), &PluginToHost::Request(req))?;
|
||||
send(&mut self.stream.borrow_mut(), &PluginToHost::Request(Box::new(req)))?;
|
||||
loop {
|
||||
match recv::<HostToPlugin>(&mut self.stream.borrow_mut())? {
|
||||
HostToPlugin::Response(resp) => return Ok(resp),
|
||||
HostToPlugin::Response(resp) => return Ok(*resp),
|
||||
HostToPlugin::Request(host_req) => {
|
||||
let resp = HostResponse::Error(format!(
|
||||
"unexpected nested host request: {host_req:?}"
|
||||
@@ -107,7 +107,7 @@ impl PluginHostApi {
|
||||
|
||||
fn fetch_document(&self) -> CadDocument {
|
||||
match self.client.request(PluginRequest::DocumentSnapshot) {
|
||||
Ok(PluginResponse::Document(doc)) => doc,
|
||||
Ok(PluginResponse::Document(doc)) => *doc,
|
||||
Ok(other) => {
|
||||
eprintln!("[plugin] unexpected DocumentSnapshot response: {other:?}");
|
||||
CadDocument::default()
|
||||
@@ -156,6 +156,25 @@ impl HostApi for PluginHostApi {
|
||||
}
|
||||
}
|
||||
|
||||
fn add_entities(&mut self, entities: Vec<EntityType>) -> Vec<Handle> {
|
||||
match self.client.request(PluginRequest::AddEntities(entities)) {
|
||||
Ok(PluginResponse::Handles(handles)) => {
|
||||
// The cached snapshot is now stale; drop it so a later
|
||||
// document() re-fetches the host's post-edit truth.
|
||||
self.document_cache = OnceCell::new();
|
||||
handles
|
||||
}
|
||||
Ok(other) => {
|
||||
eprintln!("[plugin] unexpected AddEntities response: {other:?}");
|
||||
Vec::new()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] AddEntities failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_entity(&mut self, entity: EntityType) -> bool {
|
||||
match self.client.request(PluginRequest::UpdateEntity(entity)) {
|
||||
Ok(PluginResponse::Bool(b)) => {
|
||||
@@ -369,7 +388,7 @@ impl HostApi for PluginHostApi {
|
||||
}
|
||||
}
|
||||
match self.doc_view.borrow().as_ref() {
|
||||
Some(info) => match SharedDocumentReader::open(Path::new(&info.path)) {
|
||||
Some(info) => match SharedDocumentReader::<DocumentViewData>::open(Path::new(&info.path)) {
|
||||
Ok(reader) => Box::new(reader),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
@@ -455,10 +474,13 @@ mod tests {
|
||||
let peer_handle = thread::spawn(move || {
|
||||
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||
match msg {
|
||||
PluginToHost::Request(PluginRequest::PushInfo(s)) => assert_eq!(s, "hello host"),
|
||||
PluginToHost::Request(req) => match *req {
|
||||
PluginRequest::PushInfo(s) => assert_eq!(s, "hello host"),
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
send(&mut peer, &HostToPlugin::Response(PluginResponse::Ok)).unwrap();
|
||||
send(&mut peer, &HostToPlugin::Response(Box::new(PluginResponse::Ok))).unwrap();
|
||||
});
|
||||
api.push_info("hello host");
|
||||
peer_handle.join().unwrap();
|
||||
@@ -470,12 +492,15 @@ mod tests {
|
||||
let peer_handle = thread::spawn(move || {
|
||||
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||
match msg {
|
||||
PluginToHost::Request(PluginRequest::AddEntity(_)) => {}
|
||||
PluginToHost::Request(req) => match *req {
|
||||
PluginRequest::AddEntity(_) => {}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
send(
|
||||
&mut peer,
|
||||
&HostToPlugin::Response(PluginResponse::Handle(Handle::new(42))),
|
||||
&HostToPlugin::Response(Box::new(PluginResponse::Handle(Handle::new(42)))),
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
@@ -484,16 +509,50 @@ mod tests {
|
||||
assert_eq!(handle, Handle::new(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_entities_awaits_handles_response() {
|
||||
let (mut api, mut peer) = make_client();
|
||||
let peer_handle = thread::spawn(move || {
|
||||
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||
match msg {
|
||||
PluginToHost::Request(req) => match *req {
|
||||
PluginRequest::AddEntities(v) => assert_eq!(v.len(), 3),
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
send(
|
||||
&mut peer,
|
||||
&HostToPlugin::Response(Box::new(PluginResponse::Handles(vec![
|
||||
Handle::new(10),
|
||||
Handle::new(11),
|
||||
Handle::new(12),
|
||||
]))),
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
let handles = api.add_entities(vec![
|
||||
EntityType::Point(Point::new()),
|
||||
EntityType::Point(Point::new()),
|
||||
EntityType::Point(Point::new()),
|
||||
]);
|
||||
peer_handle.join().unwrap();
|
||||
assert_eq!(handles, vec![Handle::new(10), Handle::new(11), Handle::new(12)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_entity_awaits_bool_response() {
|
||||
let (mut api, mut peer) = make_client();
|
||||
let peer_handle = thread::spawn(move || {
|
||||
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||
match msg {
|
||||
PluginToHost::Request(PluginRequest::UpdateEntity(_)) => {}
|
||||
PluginToHost::Request(req) => match *req {
|
||||
PluginRequest::UpdateEntity(_) => {}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
send(&mut peer, &HostToPlugin::Response(PluginResponse::Bool(true))).unwrap();
|
||||
send(&mut peer, &HostToPlugin::Response(Box::new(PluginResponse::Bool(true)))).unwrap();
|
||||
});
|
||||
assert!(api.update_entity(EntityType::Point(Point::new())));
|
||||
peer_handle.join().unwrap();
|
||||
@@ -505,12 +564,15 @@ mod tests {
|
||||
let peer_handle = thread::spawn(move || {
|
||||
let msg = recv::<PluginToHost>(&mut peer).unwrap();
|
||||
match msg {
|
||||
PluginToHost::Request(PluginRequest::RemoveEntity { handle }) => {
|
||||
assert_eq!(handle, Handle::new(7));
|
||||
}
|
||||
PluginToHost::Request(req) => match *req {
|
||||
PluginRequest::RemoveEntity { handle } => {
|
||||
assert_eq!(handle, Handle::new(7));
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
send(&mut peer, &HostToPlugin::Response(PluginResponse::Bool(true))).unwrap();
|
||||
send(&mut peer, &HostToPlugin::Response(Box::new(PluginResponse::Bool(true)))).unwrap();
|
||||
});
|
||||
assert!(api.remove_entity(Handle::new(7)));
|
||||
peer_handle.join().unwrap();
|
||||
|
||||
@@ -11,6 +11,10 @@ pub mod protocol;
|
||||
pub mod server;
|
||||
#[cfg(feature = "host")]
|
||||
pub mod transport;
|
||||
#[cfg(feature = "host")]
|
||||
pub mod v4;
|
||||
#[cfg(feature = "host")]
|
||||
pub mod proxy;
|
||||
|
||||
#[cfg(all(test, feature = "host"))]
|
||||
mod tests {
|
||||
@@ -58,7 +62,10 @@ mod tests {
|
||||
send(&mut a, &HostToPlugin::Request(req)).unwrap();
|
||||
let got = recv::<HostToPlugin>(&mut b).unwrap();
|
||||
match got {
|
||||
HostToPlugin::Request(HostRequest::Dispatch { cmd }) => assert_eq!(cmd, "LINE"),
|
||||
HostToPlugin::Request(req) => match req {
|
||||
HostRequest::Dispatch { cmd } => assert_eq!(cmd, "LINE"),
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
@@ -66,14 +73,71 @@ mod tests {
|
||||
#[test]
|
||||
fn transport_round_trips_plugin_request() {
|
||||
let (mut a, mut b) = connect_pair();
|
||||
let req = PluginToHost::Request(crate::ipc::protocol::PluginRequest::PushInfo(
|
||||
let req = PluginToHost::Request(Box::new(crate::ipc::protocol::PluginRequest::PushInfo(
|
||||
"hello".to_string(),
|
||||
));
|
||||
)));
|
||||
send(&mut a, &req).unwrap();
|
||||
let got = recv::<PluginToHost>(&mut b).unwrap();
|
||||
match got {
|
||||
PluginToHost::Request(crate::ipc::protocol::PluginRequest::PushInfo(msg)) => {
|
||||
assert_eq!(msg, "hello")
|
||||
PluginToHost::Request(req) => match *req {
|
||||
crate::ipc::protocol::PluginRequest::PushInfo(msg) => {
|
||||
assert_eq!(msg, "hello")
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_code_variant_roundtrips_and_appends_to_host_request() {
|
||||
// Serialize ExecuteCode and verify it deserializes back. The fact that
|
||||
// existing variants still round-trip in v2_protocol_frame_roundtrips_after_v4_changes
|
||||
// confirms the new variant is appended at the end and does not shift
|
||||
// earlier discriminants.
|
||||
let req = HostRequest::ExecuteCode {
|
||||
command_id: 1,
|
||||
source: crate::host::CommandSource::Editor,
|
||||
code: "1+1".to_string(),
|
||||
tab_index: 0,
|
||||
};
|
||||
let bytes = bincode::serialize(&req).unwrap();
|
||||
let got: HostRequest = bincode::deserialize(&bytes).unwrap();
|
||||
match got {
|
||||
HostRequest::ExecuteCode {
|
||||
command_id,
|
||||
source,
|
||||
code,
|
||||
tab_index,
|
||||
} => {
|
||||
assert_eq!(command_id, 1);
|
||||
assert_eq!(source, crate::host::CommandSource::Editor);
|
||||
assert_eq!(code, "1+1");
|
||||
assert_eq!(tab_index, 0);
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_execution_result_variant_roundtrips() {
|
||||
let result = crate::host::ExecutionResult {
|
||||
success: true,
|
||||
output: Some("ok".to_string()),
|
||||
error: None,
|
||||
error_type: None,
|
||||
traceback: None,
|
||||
line_number: None,
|
||||
column_number: None,
|
||||
duration_ms: 0.0,
|
||||
};
|
||||
let resp = HostResponse::CodeExecutionResult(result);
|
||||
let bytes = bincode::serialize(&resp).unwrap();
|
||||
let got: HostResponse = bincode::deserialize(&bytes).unwrap();
|
||||
match got {
|
||||
HostResponse::CodeExecutionResult(r) => {
|
||||
assert!(r.success);
|
||||
assert_eq!(r.output, Some("ok".to_string()));
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
@@ -109,4 +173,31 @@ mod tests {
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_protocol_frame_roundtrips_after_v4_changes() {
|
||||
// The V2/V3 wire format is unchanged by the V4 additions (new enum
|
||||
// variants are appended). Verify a full request/response pair still
|
||||
// serializes and deserializes correctly.
|
||||
let req = HostToPlugin::Request(HostRequest::Dispatch {
|
||||
cmd: "V2CMD".to_string(),
|
||||
});
|
||||
let bytes = bincode::serialize(&req).unwrap();
|
||||
let got: HostToPlugin = bincode::deserialize(&bytes).unwrap();
|
||||
match got {
|
||||
HostToPlugin::Request(req) => match req {
|
||||
HostRequest::Dispatch { cmd } => assert_eq!(cmd, "V2CMD"),
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
|
||||
let resp = PluginToHost::Response(HostResponse::Bool(true));
|
||||
let bytes = bincode::serialize(&resp).unwrap();
|
||||
let got: PluginToHost = bincode::deserialize(&bytes).unwrap();
|
||||
match got {
|
||||
PluginToHost::Response(HostResponse::Bool(b)) => assert!(b),
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::host::CommandStep;
|
||||
use crate::host::{CommandSource, CommandStep};
|
||||
use crate::manifest::ApiVersion;
|
||||
use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup};
|
||||
|
||||
@@ -27,9 +27,14 @@ pub enum InteractiveEvent {
|
||||
/// The runner proves it was spawned by this host by presenting a pre-shared
|
||||
/// token delivered through the `OCS_PLUGIN_TOKEN` environment variable. A
|
||||
/// mismatch causes the host to close the connection.
|
||||
///
|
||||
/// `TokenV4` is appended as the last variant so the existing `Token(String)`
|
||||
/// variant keeps its bincode discriminant index (0), preserving V2/V3 wire
|
||||
/// compatibility.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum RunnerHandshake {
|
||||
Token(String),
|
||||
TokenV4 { token: String, protocol_version: u32 },
|
||||
}
|
||||
|
||||
/// Environment variable through which the host passes the pre-shared
|
||||
@@ -54,6 +59,12 @@ pub enum HostRequest {
|
||||
NeedsEntityPick {
|
||||
command_id: u64,
|
||||
},
|
||||
ExecuteCode {
|
||||
command_id: u64,
|
||||
source: CommandSource,
|
||||
code: String,
|
||||
tab_index: usize,
|
||||
},
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
@@ -61,10 +72,11 @@ pub enum HostRequest {
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum HostResponse {
|
||||
Bool(bool),
|
||||
CommandStep(CommandStep),
|
||||
CommandStep(Box<CommandStep>),
|
||||
Text(String),
|
||||
Ribbon(Vec<OwnedRibbonGroup>),
|
||||
Manifest(OwnedPluginManifest),
|
||||
CodeExecutionResult(crate::host::ExecutionResult),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
@@ -75,6 +87,8 @@ pub enum PluginRequest {
|
||||
PushOutput(String),
|
||||
PushError(String),
|
||||
AddEntity(EntityType),
|
||||
/// Add multiple entities in a single request.
|
||||
AddEntities(Vec<EntityType>),
|
||||
/// Replace the existing entity carrying this entity's handle in place.
|
||||
UpdateEntity(EntityType),
|
||||
/// Delete the entity with `handle`.
|
||||
@@ -105,6 +119,13 @@ pub enum PluginRequest {
|
||||
/// Ask the host to create/refresh a shared-memory document view and return
|
||||
/// the file path + current version.
|
||||
OpenDocumentView,
|
||||
/// V4: ask the host to create/refresh a tab-keyed shared-memory document
|
||||
/// view and return the file path + current version.
|
||||
OpenDocumentViewV4 { tab_id: u64 },
|
||||
/// V4: ask the host to close the tab-keyed shared-memory document view.
|
||||
CloseDocumentViewV4 { tab_id: u64 },
|
||||
/// V4: ask the host for the stable tab identifier of the active tab.
|
||||
GetTabId,
|
||||
}
|
||||
|
||||
/// Responses the host sends back for `PluginRequest`.
|
||||
@@ -113,27 +134,35 @@ pub enum PluginResponse {
|
||||
Ok,
|
||||
Bool(bool),
|
||||
Handle(Handle),
|
||||
Handles(Vec<Handle>),
|
||||
Record(Option<ExtendedDataRecord>),
|
||||
Document(CadDocument),
|
||||
Document(Box<CadDocument>),
|
||||
Error(String),
|
||||
/// Path to the memory-mapped file and the current snapshot version.
|
||||
DocumentView {
|
||||
path: String,
|
||||
version: u64,
|
||||
},
|
||||
/// V4: path to the tab-keyed memory-mapped file and current version.
|
||||
DocumentViewV4 {
|
||||
path: String,
|
||||
version: u64,
|
||||
},
|
||||
/// V4: stable tab identifier of the active tab.
|
||||
TabId(u64),
|
||||
}
|
||||
|
||||
/// Messages sent from the host to the plugin runner.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum HostToPlugin {
|
||||
Request(HostRequest),
|
||||
Response(PluginResponse),
|
||||
Response(Box<PluginResponse>),
|
||||
}
|
||||
|
||||
/// Messages sent from the plugin runner to the host.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub enum PluginToHost {
|
||||
Request(PluginRequest),
|
||||
Request(Box<PluginRequest>),
|
||||
Response(HostResponse),
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
//! Request proxy framing for out-of-process plugins that need a worker
|
||||
//! process (e.g. a Python child) to issue host requests.
|
||||
//!
|
||||
//! The proxy is intentionally simple and separate from the V2/V3 local-socket
|
||||
//! framing. A plugin runner opens a listener, passes the port to its child, and
|
||||
//! runs [`run_request_proxy`] in a background thread. The child creates a
|
||||
//! [`ProxyPluginRequestSender`] over a stream and uses it as a
|
||||
//! [`PluginRequestSender`].
|
||||
//!
|
||||
//! This module is additive to the V4 API and does not change any V2/V3 wire
|
||||
//! formats or enum variants.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::host::{PluginRequestError, PluginRequestSender};
|
||||
use crate::ipc::protocol::{PluginRequest, PluginResponse};
|
||||
|
||||
const MAX_MESSAGE_SIZE: usize = 64 * 1024 * 1024;
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// Length of the pre-framing auth token exchanged when a child connects to
|
||||
/// the request proxy. Any local process can see the port, so the token (shared
|
||||
/// only with the spawned child via an environment variable) prevents arbitrary
|
||||
/// peers from forwarding privileged host requests.
|
||||
pub const PROXY_TOKEN_LEN: usize = 32;
|
||||
/// Interval used by [`ProxyPluginRequestSender::request_with_poll`] between
|
||||
/// response-read attempts so callers can check for signals/interrupts.
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(5);
|
||||
|
||||
/// Errors that can occur on the proxy stream.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ProxyError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("serialization error: {0}")]
|
||||
Encode(#[from] bincode::Error),
|
||||
#[error("empty message")]
|
||||
Empty,
|
||||
#[error("message too large: {0} bytes")]
|
||||
TooLarge(usize),
|
||||
}
|
||||
|
||||
impl From<ProxyError> for PluginRequestError {
|
||||
fn from(e: ProxyError) -> Self {
|
||||
PluginRequestError(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a length-framed bincode message over a generic stream.
|
||||
pub fn send_framed<W: Write>(writer: &mut W, msg: &impl serde::Serialize) -> Result<(), ProxyError> {
|
||||
let bytes = bincode::serialize(msg)?;
|
||||
if bytes.len() > MAX_MESSAGE_SIZE {
|
||||
return Err(ProxyError::TooLarge(bytes.len()));
|
||||
}
|
||||
let len = bytes.len() as u64;
|
||||
writer.write_all(&len.to_le_bytes())?;
|
||||
writer.write_all(&bytes)?;
|
||||
writer.flush()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Receive a length-framed bincode message from a generic stream.
|
||||
pub fn recv_framed<R: Read, T: serde::de::DeserializeOwned>(reader: &mut R) -> Result<T, ProxyError> {
|
||||
let mut len_buf = [0u8; 8];
|
||||
reader.read_exact(&mut len_buf)?;
|
||||
let len = u64::from_le_bytes(len_buf) as usize;
|
||||
if len == 0 {
|
||||
return Err(ProxyError::Empty);
|
||||
}
|
||||
if len > MAX_MESSAGE_SIZE {
|
||||
return Err(ProxyError::TooLarge(len));
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
reader.read_exact(&mut buf)?;
|
||||
let msg = bincode::deserialize(&buf)?;
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Thread-safe [`PluginRequestSender`] implementation that forwards requests
|
||||
/// over a single TCP stream to a request proxy server.
|
||||
pub struct ProxyPluginRequestSender {
|
||||
stream: Mutex<TcpStream>,
|
||||
}
|
||||
|
||||
impl ProxyPluginRequestSender {
|
||||
/// Connect to a request proxy server at `host:port` and authenticate with
|
||||
/// `token`. The token must match the value the proxy was started with.
|
||||
pub fn connect_with_token(
|
||||
host: &str,
|
||||
port: u16,
|
||||
token: &[u8; PROXY_TOKEN_LEN],
|
||||
) -> Result<Self, ProxyError> {
|
||||
let mut stream = TcpStream::connect((host, port))?;
|
||||
stream.write_all(token)?;
|
||||
stream.flush()?;
|
||||
stream.set_read_timeout(Some(REQUEST_TIMEOUT))?;
|
||||
Ok(Self {
|
||||
stream: Mutex::new(stream),
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a second handle to the same underlying socket so multiple
|
||||
/// callers can share the proxy connection (requests are still serialized
|
||||
/// by the mutex on each handle).
|
||||
pub fn try_clone(&self) -> Result<Self, ProxyError> {
|
||||
let stream = self
|
||||
.stream
|
||||
.lock()
|
||||
.map_err(|e| ProxyError::Io(std::io::Error::other(e.to_string())))?
|
||||
.try_clone()?;
|
||||
Ok(Self {
|
||||
stream: Mutex::new(stream),
|
||||
})
|
||||
}
|
||||
|
||||
/// Send a request and wait for the response, calling `poll` every
|
||||
/// [`POLL_INTERVAL`] while waiting. This lets Python callers run
|
||||
/// `py.check_signals()` so that `Ctrl+C`/`KeyboardInterrupt` is raised
|
||||
/// promptly instead of waiting for the full socket timeout.
|
||||
pub fn request_with_poll(
|
||||
&self,
|
||||
req: PluginRequest,
|
||||
poll: &mut dyn FnMut() -> Result<(), PluginRequestError>,
|
||||
) -> Result<PluginResponse, PluginRequestError> {
|
||||
use std::io::ErrorKind;
|
||||
let mut stream = self
|
||||
.stream
|
||||
.lock()
|
||||
.map_err(|e| PluginRequestError(e.to_string()))?;
|
||||
send_framed(&mut *stream, &req)?;
|
||||
let original_timeout = stream
|
||||
.read_timeout()
|
||||
.map_err(|e| PluginRequestError(e.to_string()))?;
|
||||
stream
|
||||
.set_read_timeout(Some(POLL_INTERVAL))
|
||||
.map_err(|e| PluginRequestError(e.to_string()))?;
|
||||
let result = loop {
|
||||
match recv_framed(&mut *stream) {
|
||||
Ok(resp) => break Ok(resp),
|
||||
Err(ProxyError::Io(e))
|
||||
if e.kind() == ErrorKind::WouldBlock
|
||||
|| e.kind() == ErrorKind::TimedOut =>
|
||||
{
|
||||
if let Err(e) = poll() {
|
||||
break Err(e);
|
||||
}
|
||||
}
|
||||
Err(e) => break Err(e.into()),
|
||||
}
|
||||
};
|
||||
let _ = stream.set_read_timeout(original_timeout);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginRequestSender for ProxyPluginRequestSender {
|
||||
fn request(&self, req: PluginRequest) -> Result<PluginResponse, PluginRequestError> {
|
||||
let mut stream = self
|
||||
.stream
|
||||
.lock()
|
||||
.map_err(|e| PluginRequestError(e.to_string()))?;
|
||||
send_framed(&mut *stream, &req)?;
|
||||
let resp: PluginResponse = recv_framed(&mut *stream)?;
|
||||
Ok(resp)
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the first [`PROXY_TOKEN_LEN`] bytes from `stream` and verify they
|
||||
/// match `expected`. Returns `false` if the peer sent the wrong token or
|
||||
/// disconnected.
|
||||
fn verify_token(stream: &mut TcpStream, expected: &[u8; PROXY_TOKEN_LEN]) -> bool {
|
||||
let mut buf = [0u8; PROXY_TOKEN_LEN];
|
||||
match stream.read_exact(&mut buf) {
|
||||
Ok(()) => {}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] proxy failed to read auth token: {e}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if buf != *expected {
|
||||
eprintln!("[plugin] proxy auth token mismatch; closing connection");
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Run a request proxy server on `listener`. For each incoming connection a
|
||||
/// thread is spawned that reads [`PluginRequest`]s, forwards them through
|
||||
/// `sender`, and writes back [`PluginResponse`]s. The function returns only
|
||||
/// when the listener is closed or encounters an error.
|
||||
///
|
||||
/// `token` must be sent by the client before the length-framed protocol begins.
|
||||
pub fn run_request_proxy(
|
||||
listener: TcpListener,
|
||||
sender: Arc<dyn PluginRequestSender>,
|
||||
token: [u8; PROXY_TOKEN_LEN],
|
||||
) -> std::io::Result<()> {
|
||||
for stream in listener.incoming() {
|
||||
let mut stream = match stream {
|
||||
Ok(s) => s,
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if !verify_token(&mut stream, &token) {
|
||||
continue;
|
||||
}
|
||||
let sender = Arc::clone(&sender);
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
let req: PluginRequest = match recv_framed(&mut stream) {
|
||||
Ok(r) => r,
|
||||
Err(_) => break,
|
||||
};
|
||||
let resp = match sender.request(req) {
|
||||
Ok(r) => r,
|
||||
Err(e) => PluginResponse::Error(e.to_string()),
|
||||
};
|
||||
if send_framed(&mut stream, &resp).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run a request proxy server on `listener` with an explicit shutdown signal.
|
||||
/// Same behavior as [`run_request_proxy`], but returns when `shutdown` receives
|
||||
/// a value, closing the listener and terminating the server thread.
|
||||
pub fn run_request_proxy_with_shutdown(
|
||||
listener: TcpListener,
|
||||
sender: Arc<dyn PluginRequestSender>,
|
||||
token: [u8; PROXY_TOKEN_LEN],
|
||||
shutdown: std::sync::mpsc::Receiver<()>,
|
||||
) -> std::io::Result<()> {
|
||||
listener.set_nonblocking(true)?;
|
||||
let mut incoming = listener.incoming();
|
||||
loop {
|
||||
match incoming.next() {
|
||||
Some(Ok(mut stream)) => {
|
||||
// Accepted streams may inherit the listener's non-blocking mode;
|
||||
// force blocking mode so recv_framed/read_exact work.
|
||||
let _ = stream.set_nonblocking(false);
|
||||
if !verify_token(&mut stream, &token) {
|
||||
continue;
|
||||
}
|
||||
let sender = Arc::clone(&sender);
|
||||
std::thread::spawn(move || {
|
||||
let mut stream = stream;
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
loop {
|
||||
let req: PluginRequest = match recv_framed(&mut stream) {
|
||||
Ok(r) => {
|
||||
// Log only the variant; large payloads such
|
||||
// as AddEntities(10000 points) are expensive
|
||||
// to Debug-format and are not human-readable.
|
||||
eprintln!(
|
||||
"[plugin] proxy recv request: {:?}",
|
||||
std::mem::discriminant(&r)
|
||||
);
|
||||
r
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] proxy recv error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
let resp = match sender.request(req) {
|
||||
Ok(r) => r,
|
||||
Err(e) => PluginResponse::Error(e.to_string()),
|
||||
};
|
||||
eprintln!(
|
||||
"[plugin] proxy forwarding response: {:?}",
|
||||
std::mem::discriminant(&resp)
|
||||
);
|
||||
if let Err(e) = send_framed(&mut stream, &resp) {
|
||||
eprintln!("[plugin] proxy send error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}));
|
||||
if let Err(payload) = result {
|
||||
let msg = payload
|
||||
.downcast_ref::<&str>()
|
||||
.map(|s| (*s).to_string())
|
||||
.or_else(|| payload.downcast_ref::<String>().cloned())
|
||||
.unwrap_or_else(|| "proxy connection thread panicked".to_string());
|
||||
eprintln!("[plugin] request proxy connection panic: {msg}");
|
||||
}
|
||||
eprintln!("[plugin] proxy connection handler exiting");
|
||||
});
|
||||
}
|
||||
Some(Err(e)) if e.kind() == std::io::ErrorKind::WouldBlock => {
|
||||
if shutdown.try_recv().is_ok() {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(10));
|
||||
}
|
||||
Some(Err(e)) => return Err(e),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
struct EchoSender;
|
||||
impl PluginRequestSender for EchoSender {
|
||||
fn request(&self, req: PluginRequest) -> Result<PluginResponse, PluginRequestError> {
|
||||
match req {
|
||||
PluginRequest::PushInfo(_) => Ok(PluginResponse::Ok),
|
||||
_ => Ok(PluginResponse::Bool(false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_round_trips_plugin_request() {
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
let token = [42u8; PROXY_TOKEN_LEN];
|
||||
thread::spawn(move || {
|
||||
let sender: Arc<dyn PluginRequestSender> = Arc::new(EchoSender);
|
||||
run_request_proxy(listener, sender, token).ok();
|
||||
});
|
||||
|
||||
let sender = ProxyPluginRequestSender::connect_with_token("127.0.0.1", port, &token).unwrap();
|
||||
let resp = sender
|
||||
.request(PluginRequest::PushInfo("hello proxy".to_string()))
|
||||
.unwrap();
|
||||
assert!(matches!(resp, PluginResponse::Ok));
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@ pub fn handle_plugin_request(
|
||||
PluginResponse::Ok
|
||||
}
|
||||
AddEntity(entity) => PluginResponse::Handle(host.add_entity(entity)),
|
||||
AddEntities(entities) => PluginResponse::Handles(host.add_entities(entities)),
|
||||
UpdateEntity(entity) => PluginResponse::Bool(host.update_entity(entity)),
|
||||
RemoveEntity { handle } => PluginResponse::Bool(host.remove_entity(handle)),
|
||||
BumpGeometry => {
|
||||
@@ -53,7 +54,7 @@ pub fn handle_plugin_request(
|
||||
on_start_interactive(command_id);
|
||||
PluginResponse::Ok
|
||||
}
|
||||
DocumentSnapshot => PluginResponse::Document(host.document().clone()),
|
||||
DocumentSnapshot => PluginResponse::Document(Box::new(host.document().clone())),
|
||||
OpenDocumentView => match host.document_view() {
|
||||
Some(info) => PluginResponse::DocumentView {
|
||||
path: info.path,
|
||||
@@ -61,5 +62,17 @@ pub fn handle_plugin_request(
|
||||
},
|
||||
None => PluginResponse::Error("shared document view unavailable".to_string()),
|
||||
},
|
||||
OpenDocumentViewV4 { tab_id } => match host.document_view_v4(tab_id) {
|
||||
Some(info) => PluginResponse::DocumentViewV4 {
|
||||
path: info.path,
|
||||
version: info.version,
|
||||
},
|
||||
None => PluginResponse::Error("V4 shared document view unavailable".to_string()),
|
||||
},
|
||||
CloseDocumentViewV4 { tab_id } => {
|
||||
host.close_document_view_v4(tab_id);
|
||||
PluginResponse::Ok
|
||||
}
|
||||
GetTabId => PluginResponse::TabId(host.tab_id()),
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,8 @@ pub enum TransportError {
|
||||
Empty,
|
||||
#[error("message too large: {0} bytes")]
|
||||
TooLarge(usize),
|
||||
#[error("peer disconnected")]
|
||||
Disconnected,
|
||||
}
|
||||
|
||||
/// Send a length-framed serialized message.
|
||||
@@ -38,7 +40,13 @@ pub fn send<T: Serialize>(stream: &mut Stream, msg: &T) -> Result<(), TransportE
|
||||
/// Receive a length-framed serialized message.
|
||||
pub fn recv<T: DeserializeOwned>(stream: &mut Stream) -> Result<T, TransportError> {
|
||||
let mut len_buf = [0u8; 8];
|
||||
stream.read_exact(&mut len_buf)?;
|
||||
if let Err(e) = stream.read_exact(&mut len_buf) {
|
||||
return if e.kind() == std::io::ErrorKind::UnexpectedEof {
|
||||
Err(TransportError::Disconnected)
|
||||
} else {
|
||||
Err(TransportError::Io(e))
|
||||
};
|
||||
}
|
||||
let len = u64::from_le_bytes(len_buf) as usize;
|
||||
if len == 0 {
|
||||
return Err(TransportError::Empty);
|
||||
@@ -47,6 +55,12 @@ pub fn recv<T: DeserializeOwned>(stream: &mut Stream) -> Result<T, TransportErro
|
||||
return Err(TransportError::TooLarge(len));
|
||||
}
|
||||
let mut buf = vec![0u8; len];
|
||||
stream.read_exact(&mut buf)?;
|
||||
if let Err(e) = stream.read_exact(&mut buf) {
|
||||
return if e.kind() == std::io::ErrorKind::UnexpectedEof {
|
||||
Err(TransportError::Disconnected)
|
||||
} else {
|
||||
Err(TransportError::Io(e))
|
||||
};
|
||||
}
|
||||
Ok(bincode::deserialize(&buf)?)
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
//! Plugin-side V4 IPC client and `HostApi` proxy.
|
||||
|
||||
use std::any::Any;
|
||||
use std::cell::{Cell, OnceCell, RefCell};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use acadrust::xdata::ExtendedDataRecord;
|
||||
use acadrust::{CadDocument, EntityType, Handle};
|
||||
use interprocess::local_socket::traits::Stream as StreamTrait;
|
||||
use interprocess::local_socket::{GenericNamespaced, Stream, ToNsName};
|
||||
use interprocess::TryClone;
|
||||
|
||||
use crate::host::{
|
||||
DocumentReader, ExecutionResult, HostApi, HostNotification, InteractiveCommand,
|
||||
PluginNotification, PluginRequestSender, PluginRequestError, ReaderEntity,
|
||||
};
|
||||
use crate::ipc::protocol::{
|
||||
HostRequest, HostResponse, PluginRequest, PluginResponse, RunnerHandshake,
|
||||
};
|
||||
use crate::ipc::transport::{recv, send, TransportError};
|
||||
use crate::ipc::v4::protocol::{
|
||||
HostToPluginV4, NotificationEnvelope, PluginToHostV4, V4_PROTOCOL_VERSION,
|
||||
};
|
||||
use crate::shm::{DocumentViewInfo, SharedDocumentReader, DocumentViewData};
|
||||
|
||||
/// Default capacity of the bounded host→plugin notification queue.
|
||||
const DEFAULT_NOTIFY_QUEUE_SIZE: usize = 1024;
|
||||
|
||||
/// Minimum interval between verbose drop logs to avoid spam.
|
||||
const DROP_LOG_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
/// Bounded queue of host→plugin notifications delivered to the plugin runner
|
||||
/// loop and/or `HostApi::try_recv_notification`.
|
||||
type NotificationQueue = mpsc::Receiver<(Option<u64>, HostNotification)>;
|
||||
|
||||
/// Shared state between the plugin main thread, the reader thread, and any
|
||||
/// cloned `V4PluginHostApi` proxies.
|
||||
struct Shared {
|
||||
writer: Mutex<Stream>,
|
||||
next_id: AtomicU64,
|
||||
in_flight: Mutex<Option<mpsc::Sender<(u64, PluginResponse)>>>,
|
||||
}
|
||||
|
||||
/// A frame the V4 reader thread delivers to the plugin runner loop.
|
||||
pub enum RunnerFrame {
|
||||
Request { id: u64, payload: HostRequest },
|
||||
}
|
||||
|
||||
/// Plugin-side V4 connection to the host.
|
||||
pub struct V4Client {
|
||||
shared: Arc<Shared>,
|
||||
notifications: Arc<Mutex<NotificationQueue>>,
|
||||
runner_queue: mpsc::Receiver<RunnerFrame>,
|
||||
}
|
||||
|
||||
impl V4Client {
|
||||
/// Connect to the host socket, send the V4 handshake, and start the reader
|
||||
/// thread. The writer half is kept for synchronous `HostApi` calls; the
|
||||
/// reader half is moved into a background thread.
|
||||
pub fn connect_handshake(
|
||||
name: &str,
|
||||
token: &str,
|
||||
) -> Result<Self, crate::ipc::transport::TransportError> {
|
||||
let name = name
|
||||
.to_ns_name::<GenericNamespaced>()
|
||||
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e))?;
|
||||
let mut stream = StreamTrait::connect(name)?;
|
||||
|
||||
// Send the V4 handshake on the unsplit stream before spawning the
|
||||
// reader thread.
|
||||
send(
|
||||
&mut stream,
|
||||
&RunnerHandshake::TokenV4 {
|
||||
token: token.to_string(),
|
||||
protocol_version: V4_PROTOCOL_VERSION,
|
||||
},
|
||||
)?;
|
||||
|
||||
// Split the stream into read/write halves so the reader can block on
|
||||
// `recv` without stalling synchronous sends from the main thread.
|
||||
let reader = stream.try_clone().map_err(|e| {
|
||||
crate::ipc::transport::TransportError::Io(std::io::Error::other(format!(
|
||||
"stream try_clone failed: {e}"
|
||||
)))
|
||||
})?;
|
||||
|
||||
let (notify_tx, notify_rx) =
|
||||
mpsc::sync_channel(notify_queue_size());
|
||||
let (runner_tx, runner_rx) = mpsc::channel();
|
||||
|
||||
let shared = Arc::new(Shared {
|
||||
writer: Mutex::new(stream),
|
||||
next_id: AtomicU64::new(1),
|
||||
in_flight: Mutex::new(None),
|
||||
});
|
||||
|
||||
let shared_for_reader = Arc::clone(&shared);
|
||||
let notifications = Arc::new(Mutex::new(notify_rx));
|
||||
std::thread::spawn(move || {
|
||||
reader_thread(
|
||||
reader,
|
||||
shared_for_reader,
|
||||
notify_tx,
|
||||
runner_tx,
|
||||
)
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
shared,
|
||||
notifications,
|
||||
runner_queue: runner_rx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Block until the next host request arrives for the runner loop.
|
||||
pub fn recv_runner_frame(&self) -> Result<RunnerFrame, mpsc::RecvError> {
|
||||
self.runner_queue.recv()
|
||||
}
|
||||
|
||||
/// Non-blocking poll for host-to-plugin notifications.
|
||||
pub fn try_recv_notification(&self) -> Option<(Option<u64>, HostNotification)> {
|
||||
self.notifications.lock().unwrap_or_else(|e| e.into_inner()).try_recv().ok()
|
||||
}
|
||||
|
||||
/// Send a plugin response for a host request.
|
||||
pub fn send_response(
|
||||
&self,
|
||||
id: u64,
|
||||
resp: HostResponse,
|
||||
) -> Result<(), crate::ipc::transport::TransportError> {
|
||||
let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
send(
|
||||
&mut writer,
|
||||
&PluginToHostV4::Response { id, payload: resp },
|
||||
)
|
||||
}
|
||||
|
||||
/// Send a plugin-to-host notification.
|
||||
pub fn notify_plugin(
|
||||
&self,
|
||||
command_id: Option<u64>,
|
||||
notification: PluginNotification,
|
||||
) {
|
||||
let envelope = NotificationEnvelope {
|
||||
command_id,
|
||||
payload: notification,
|
||||
};
|
||||
let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Err(e) = send(&mut writer, &PluginToHostV4::Notification(envelope)) {
|
||||
eprintln!("[plugin] notify_plugin failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a callback that sends a delayed `CodeExecutionResult` response for
|
||||
/// the given request id. Used by `start_execute_code` implementations that
|
||||
/// run REPL code on a background thread.
|
||||
pub fn execute_code_responder(
|
||||
&self,
|
||||
id: u64,
|
||||
) -> Box<dyn FnOnce(ExecutionResult) + Send> {
|
||||
let shared = Arc::clone(&self.shared);
|
||||
Box::new(move |result| {
|
||||
let mut writer = shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let _ = send(
|
||||
&mut writer,
|
||||
&PluginToHostV4::Response {
|
||||
id,
|
||||
payload: HostResponse::CodeExecutionResult(result),
|
||||
},
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a `HostApi` proxy backed by this V4 connection.
|
||||
pub fn plugin_host_api(
|
||||
&self,
|
||||
tab_index: usize,
|
||||
interactive: Rc<RefCell<HashMap<u64, Box<dyn InteractiveCommand>>>>,
|
||||
) -> V4PluginHostApi {
|
||||
V4PluginHostApi::new(
|
||||
Arc::clone(&self.shared),
|
||||
Arc::clone(&self.notifications),
|
||||
tab_index,
|
||||
interactive,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn notify_queue_size() -> usize {
|
||||
std::env::var("OCS_PLUGIN_NOTIFY_QUEUE_SIZE")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(DEFAULT_NOTIFY_QUEUE_SIZE)
|
||||
.max(1)
|
||||
}
|
||||
|
||||
/// Background reader thread: deserialize frames and route them to the
|
||||
/// appropriate sink. Panics are caught so one bad frame does not escape the
|
||||
/// thread; the error is logged and the thread exits, which closes the shared
|
||||
/// channels and signals process death to waiters.
|
||||
fn reader_thread(
|
||||
mut reader: Stream,
|
||||
shared: Arc<Shared>,
|
||||
notify_tx: mpsc::SyncSender<(Option<u64>, HostNotification)>,
|
||||
runner_tx: mpsc::Sender<RunnerFrame>,
|
||||
) {
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut last_drop_log: Option<Instant> = None;
|
||||
loop {
|
||||
match recv::<HostToPluginV4>(&mut reader) {
|
||||
Ok(HostToPluginV4::Response { id, payload }) => {
|
||||
if let Some(tx) = shared.in_flight.lock().unwrap_or_else(|e| e.into_inner()).take() {
|
||||
let _ = tx.send((id, payload));
|
||||
} else {
|
||||
eprintln!("[plugin] V4 reader: unexpected response id={id}");
|
||||
}
|
||||
}
|
||||
Ok(HostToPluginV4::Notification(envelope)) => {
|
||||
if notify_tx.try_send((envelope.command_id, envelope.payload)).is_err() {
|
||||
let now = Instant::now();
|
||||
let should_log = last_drop_log
|
||||
.map(|t| now.duration_since(t) >= DROP_LOG_INTERVAL)
|
||||
.unwrap_or(true);
|
||||
if should_log && crate::process::verbose() {
|
||||
eprintln!("[plugin] V4 notification queue full; dropping newest");
|
||||
last_drop_log = Some(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(HostToPluginV4::Request { id, payload }) => {
|
||||
if runner_tx.send(RunnerFrame::Request { id, payload }).is_err() {
|
||||
// Runner loop has exited; stop reading.
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(TransportError::Disconnected) => {
|
||||
// Host closed the connection; normal during shutdown.
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] V4 reader error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
// Ensure any synchronous waiter is unblocked.
|
||||
let _ = shared.in_flight.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||
}
|
||||
|
||||
/// `HostApi` implementation used inside the plugin process over the V4
|
||||
/// multiplexed socket.
|
||||
pub struct V4PluginHostApi {
|
||||
shared: Arc<Shared>,
|
||||
notifications: Arc<Mutex<NotificationQueue>>,
|
||||
tab_index: usize,
|
||||
document_cache: OnceCell<CadDocument>,
|
||||
interactive: Rc<RefCell<HashMap<u64, Box<dyn InteractiveCommand>>>>,
|
||||
next_command_id: Cell<u64>,
|
||||
record_cache: RefCell<HashMap<(Handle, String), &'static ExtendedDataRecord>>,
|
||||
doc_view: RefCell<Option<DocumentViewInfo>>,
|
||||
doc_view_v4: RefCell<Option<DocumentViewInfo>>,
|
||||
tab_id_cache: Cell<Option<u64>>,
|
||||
}
|
||||
|
||||
impl V4PluginHostApi {
|
||||
fn new(
|
||||
shared: Arc<Shared>,
|
||||
notifications: Arc<Mutex<NotificationQueue>>,
|
||||
tab_index: usize,
|
||||
interactive: Rc<RefCell<HashMap<u64, Box<dyn InteractiveCommand>>>>,
|
||||
) -> Self {
|
||||
Self {
|
||||
shared,
|
||||
notifications,
|
||||
tab_index,
|
||||
document_cache: OnceCell::new(),
|
||||
interactive,
|
||||
next_command_id: Cell::new(1),
|
||||
record_cache: RefCell::new(HashMap::new()),
|
||||
doc_view: RefCell::new(None),
|
||||
doc_view_v4: RefCell::new(None),
|
||||
tab_id_cache: Cell::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn request(
|
||||
&self,
|
||||
req: PluginRequest,
|
||||
) -> Result<PluginResponse, crate::ipc::transport::TransportError> {
|
||||
send_plugin_request(&self.shared, req)
|
||||
}
|
||||
|
||||
fn fetch_document(&self) -> CadDocument {
|
||||
match self.request(PluginRequest::DocumentSnapshot) {
|
||||
Ok(PluginResponse::Document(doc)) => *doc,
|
||||
Ok(other) => {
|
||||
eprintln!("[plugin] unexpected DocumentSnapshot response: {other:?}");
|
||||
CadDocument::default()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] failed to fetch document snapshot: {e}");
|
||||
CadDocument::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send a single plugin-to-host request using the shared V4 connection state.
|
||||
/// This is the inner logic shared by [`V4PluginHostApi::request`] and the
|
||||
/// thread-safe [`V4PluginRequestSender`].
|
||||
fn send_plugin_request(
|
||||
shared: &Arc<Shared>,
|
||||
req: PluginRequest,
|
||||
) -> Result<PluginResponse, crate::ipc::transport::TransportError> {
|
||||
let id = shared.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = mpsc::channel();
|
||||
*shared.in_flight.lock().unwrap_or_else(|e| e.into_inner()) = Some(tx);
|
||||
{
|
||||
let mut writer = shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
send(&mut writer, &PluginToHostV4::Request { id, payload: req })?;
|
||||
}
|
||||
match rx.recv() {
|
||||
Ok((resp_id, resp)) if resp_id == id => Ok(resp),
|
||||
Ok((resp_id, _)) => Err(crate::ipc::transport::TransportError::Io(
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!("V4 response id mismatch: expected {id}, got {resp_id}"),
|
||||
),
|
||||
)),
|
||||
Err(_) => Err(crate::ipc::transport::TransportError::Io(
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::NotConnected,
|
||||
"V4 reader thread disconnected",
|
||||
),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Thread-safe wrapper around the V4 connection so worker threads can issue
|
||||
/// host requests. A per-sender mutex serializes requests, matching the
|
||||
/// single-in-flight behavior of [`V4PluginHostApi::request`].
|
||||
struct V4PluginRequestSender {
|
||||
shared: Arc<Shared>,
|
||||
lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl PluginRequestSender for V4PluginRequestSender {
|
||||
fn request(&self, req: PluginRequest) -> Result<PluginResponse, PluginRequestError> {
|
||||
let _guard = self.lock.lock().map_err(|e| PluginRequestError(e.to_string()))?;
|
||||
send_plugin_request(&self.shared, req).map_err(|e| PluginRequestError(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
impl HostApi for V4PluginHostApi {
|
||||
fn tab_index(&self) -> usize {
|
||||
self.tab_index
|
||||
}
|
||||
|
||||
fn document(&self) -> &CadDocument {
|
||||
self.document_cache.get_or_init(|| self.fetch_document())
|
||||
}
|
||||
|
||||
fn document_mut(&mut self) -> &mut CadDocument {
|
||||
if self.document_cache.get().is_none() {
|
||||
let doc = self.fetch_document();
|
||||
let _ = self.document_cache.set(doc);
|
||||
}
|
||||
self.document_cache.get_mut().expect("document initialized")
|
||||
}
|
||||
|
||||
fn add_entity(&mut self, entity: EntityType) -> Handle {
|
||||
match self.request(PluginRequest::AddEntity(entity)) {
|
||||
Ok(PluginResponse::Handle(h)) => {
|
||||
self.document_cache = OnceCell::new();
|
||||
h
|
||||
}
|
||||
Ok(other) => {
|
||||
eprintln!("[plugin] unexpected AddEntity response: {other:?}");
|
||||
Handle::default()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] AddEntity failed: {e}");
|
||||
Handle::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn add_entities(&mut self, entities: Vec<EntityType>) -> Vec<Handle> {
|
||||
match self.request(PluginRequest::AddEntities(entities)) {
|
||||
Ok(PluginResponse::Handles(handles)) => {
|
||||
self.document_cache = OnceCell::new();
|
||||
handles
|
||||
}
|
||||
Ok(other) => {
|
||||
eprintln!("[plugin] unexpected AddEntities response: {other:?}");
|
||||
Vec::new()
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] AddEntities failed: {e}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update_entity(&mut self, entity: EntityType) -> bool {
|
||||
match self.request(PluginRequest::UpdateEntity(entity)) {
|
||||
Ok(PluginResponse::Bool(b)) => {
|
||||
if b {
|
||||
self.document_cache = OnceCell::new();
|
||||
}
|
||||
b
|
||||
}
|
||||
Ok(other) => {
|
||||
eprintln!("[plugin] unexpected UpdateEntity response: {other:?}");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] UpdateEntity failed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_entity(&mut self, handle: Handle) -> bool {
|
||||
match self.request(PluginRequest::RemoveEntity { handle }) {
|
||||
Ok(PluginResponse::Bool(b)) => {
|
||||
if b {
|
||||
self.document_cache = OnceCell::new();
|
||||
}
|
||||
b
|
||||
}
|
||||
Ok(other) => {
|
||||
eprintln!("[plugin] unexpected RemoveEntity response: {other:?}");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] RemoveEntity failed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn bump_geometry(&mut self) {
|
||||
let _ = self.request(PluginRequest::BumpGeometry);
|
||||
}
|
||||
|
||||
fn read_record(&self, handle: Handle, app_name: &str) -> Option<&ExtendedDataRecord> {
|
||||
let key = (handle, app_name.to_string());
|
||||
{
|
||||
let cache = self.record_cache.borrow();
|
||||
if let Some(&r) = cache.get(&key) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
match self.request(PluginRequest::ReadRecord {
|
||||
handle,
|
||||
app_name: app_name.to_string(),
|
||||
}) {
|
||||
Ok(PluginResponse::Record(rec)) => rec.map(|r| {
|
||||
let leaked: &'static ExtendedDataRecord = Box::leak(Box::new(r));
|
||||
self.record_cache.borrow_mut().insert(key, leaked);
|
||||
leaked
|
||||
}),
|
||||
Ok(other) => {
|
||||
eprintln!("[plugin] unexpected ReadRecord response: {other:?}");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] ReadRecord failed: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn write_record(&mut self, handle: Handle, record: ExtendedDataRecord) -> bool {
|
||||
let app = record.application_name.clone();
|
||||
match self
|
||||
.request(PluginRequest::WriteRecord { handle, record })
|
||||
{
|
||||
Ok(PluginResponse::Bool(b)) => {
|
||||
if b {
|
||||
self.record_cache.borrow_mut().remove(&(handle, app));
|
||||
}
|
||||
b
|
||||
}
|
||||
Ok(other) => {
|
||||
eprintln!("[plugin] unexpected WriteRecord response: {other:?}");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] WriteRecord failed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn remove_record(&mut self, handle: Handle, app_name: &str) -> bool {
|
||||
match self.request(PluginRequest::RemoveRecord {
|
||||
handle,
|
||||
app_name: app_name.to_string(),
|
||||
}) {
|
||||
Ok(PluginResponse::Bool(b)) => {
|
||||
if b {
|
||||
self.record_cache
|
||||
.borrow_mut()
|
||||
.remove(&(handle, app_name.to_string()));
|
||||
}
|
||||
b
|
||||
}
|
||||
Ok(other) => {
|
||||
eprintln!("[plugin] unexpected RemoveRecord response: {other:?}");
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] RemoveRecord failed: {e}");
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_undo(&mut self, label: &str) {
|
||||
if let Err(e) = self.request(PluginRequest::PushUndo {
|
||||
label: label.to_string(),
|
||||
}) {
|
||||
eprintln!("[plugin] push_undo failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn set_dirty(&mut self) {
|
||||
if let Err(e) = self.request(PluginRequest::SetDirty) {
|
||||
eprintln!("[plugin] set_dirty failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn push_info(&mut self, msg: &str) {
|
||||
if let Err(e) = self.request(PluginRequest::PushInfo(msg.to_string())) {
|
||||
eprintln!("[plugin] push_info failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn push_output(&mut self, msg: &str) {
|
||||
if let Err(e) = self.request(PluginRequest::PushOutput(msg.to_string())) {
|
||||
eprintln!("[plugin] push_output failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn push_error(&mut self, msg: &str) {
|
||||
if let Err(e) = self.request(PluginRequest::PushError(msg.to_string())) {
|
||||
eprintln!("[plugin] push_error failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn start_interactive(&mut self, command: Box<dyn InteractiveCommand>) {
|
||||
let id = self.next_command_id.get();
|
||||
self.next_command_id.set(id + 1);
|
||||
self.interactive.borrow_mut().insert(id, command);
|
||||
if let Err(e) = self.request(PluginRequest::StartInteractive { command_id: id }) {
|
||||
eprintln!("[plugin] start_interactive failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn plugin_state_any(&self, _plugin_id: &str) -> Option<&(dyn Any + Send + Sync)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn plugin_state_any_mut(&mut self, _plugin_id: &str) -> Option<&mut (dyn Any + Send + Sync)> {
|
||||
None
|
||||
}
|
||||
|
||||
fn ensure_plugin_state_any(
|
||||
&mut self,
|
||||
_plugin_id: &'static str,
|
||||
_init: &mut dyn FnMut() -> Box<dyn Any + Send + Sync>,
|
||||
) -> &mut (dyn Any + Send + Sync) {
|
||||
panic!("ensure_plugin_state is not supported for out-of-process plugins; keep state in the plugin crate")
|
||||
}
|
||||
|
||||
fn document_reader(&self) -> Box<dyn DocumentReader + '_> {
|
||||
{
|
||||
let mut view = self.doc_view.borrow_mut();
|
||||
if view.is_none() {
|
||||
match self.request(PluginRequest::OpenDocumentView) {
|
||||
Ok(PluginResponse::DocumentView { path, version }) => {
|
||||
*view = Some(DocumentViewInfo { path, version });
|
||||
}
|
||||
Ok(other) => {
|
||||
eprintln!("[plugin] unexpected OpenDocumentView response: {other:?}");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] OpenDocumentView request failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match self.doc_view.borrow().as_ref() {
|
||||
Some(info) => match SharedDocumentReader::<DocumentViewData>::open(Path::new(&info.path)) {
|
||||
Ok(reader) => Box::new(reader),
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"[plugin] failed to open document view at {}: {e}",
|
||||
info.path
|
||||
);
|
||||
Box::new(EmptyDocumentReader)
|
||||
}
|
||||
},
|
||||
None => Box::new(EmptyDocumentReader),
|
||||
}
|
||||
}
|
||||
|
||||
fn notify_plugin(&mut self, command_id: Option<u64>, notification: PluginNotification) {
|
||||
let envelope = NotificationEnvelope {
|
||||
command_id,
|
||||
payload: notification,
|
||||
};
|
||||
let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if let Err(e) = send(&mut writer, &PluginToHostV4::Notification(envelope)) {
|
||||
eprintln!("[plugin] notify_plugin failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
fn try_recv_notification(&mut self) -> Option<(Option<u64>, HostNotification)> {
|
||||
self.notifications.lock().unwrap_or_else(|e| e.into_inner()).try_recv().ok()
|
||||
}
|
||||
|
||||
fn plugin_request_sender(&self) -> Option<Box<dyn PluginRequestSender>> {
|
||||
Some(Box::new(V4PluginRequestSender {
|
||||
shared: Arc::clone(&self.shared),
|
||||
lock: Mutex::new(()),
|
||||
}))
|
||||
}
|
||||
|
||||
fn tab_id(&self) -> u64 {
|
||||
if let Some(id) = self.tab_id_cache.get() {
|
||||
return id;
|
||||
}
|
||||
match self.request(PluginRequest::GetTabId) {
|
||||
Ok(PluginResponse::TabId(id)) => {
|
||||
self.tab_id_cache.set(Some(id));
|
||||
id
|
||||
}
|
||||
Ok(other) => {
|
||||
eprintln!("[plugin] unexpected GetTabId response: {other:?}");
|
||||
self.tab_index as u64
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] GetTabId failed: {e}");
|
||||
self.tab_index as u64
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn document_view_v4(&mut self, _tab_id: u64) -> Option<DocumentViewInfo> {
|
||||
{
|
||||
let mut view = self.doc_view_v4.borrow_mut();
|
||||
if view.is_none() {
|
||||
let tab_id = self.tab_id();
|
||||
match self.request(PluginRequest::OpenDocumentViewV4 { tab_id }) {
|
||||
Ok(PluginResponse::DocumentViewV4 { path, version }) => {
|
||||
*view = Some(DocumentViewInfo { path, version });
|
||||
}
|
||||
Ok(other) => {
|
||||
eprintln!("[plugin] unexpected OpenDocumentViewV4 response: {other:?}");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[plugin] OpenDocumentViewV4 request failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.doc_view_v4.borrow().clone()
|
||||
}
|
||||
|
||||
fn close_document_view_v4(&mut self, _tab_id: u64) {
|
||||
let tab_id = self.tab_id();
|
||||
let _ = self.request(PluginRequest::CloseDocumentViewV4 { tab_id });
|
||||
self.doc_view_v4.borrow_mut().take();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl V4Client {
|
||||
/// Test-only constructor that splits `stream` into a reader/writer pair
|
||||
/// and starts the reader thread without performing a handshake.
|
||||
pub(crate) fn from_stream_for_test(stream: Stream) -> Self {
|
||||
let reader = stream.try_clone().expect("try_clone");
|
||||
let (notify_tx, notify_rx) = mpsc::sync_channel(notify_queue_size());
|
||||
let (runner_tx, runner_rx) = mpsc::channel();
|
||||
let shared = Arc::new(Shared {
|
||||
writer: Mutex::new(stream),
|
||||
next_id: AtomicU64::new(1),
|
||||
in_flight: Mutex::new(None),
|
||||
});
|
||||
let shared_for_reader = Arc::clone(&shared);
|
||||
let notifications = Arc::new(Mutex::new(notify_rx));
|
||||
std::thread::spawn(move || {
|
||||
reader_thread(reader, shared_for_reader, notify_tx, runner_tx)
|
||||
});
|
||||
Self {
|
||||
shared,
|
||||
notifications,
|
||||
runner_queue: runner_rx,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptyDocumentReader;
|
||||
|
||||
impl DocumentReader for EmptyDocumentReader {
|
||||
fn entity_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
fn for_each_entity(&self, _f: &mut dyn FnMut(ReaderEntity<'_>)) {}
|
||||
fn layer_name(&self, _handle: Handle) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
fn app_id_name(&self, _handle: Handle) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "host"))]
|
||||
mod tests {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Mutex as StdMutex;
|
||||
use std::thread;
|
||||
|
||||
use interprocess::local_socket::{
|
||||
traits::{Listener, Stream as StreamTrait},
|
||||
GenericNamespaced, ListenerOptions, Stream, ToNsName,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::ipc::transport::recv;
|
||||
use crate::ipc::v4::protocol::{HostToPluginV4, PluginToHostV4};
|
||||
use acadrust::entities::Point;
|
||||
|
||||
static ENV_LOCK: StdMutex<()> = StdMutex::new(());
|
||||
|
||||
fn unique_socket_name() -> String {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("ocs_plugin_v4_client_test_{}_{}", std::process::id(), n)
|
||||
}
|
||||
|
||||
fn connect_pair() -> (Stream, Stream) {
|
||||
let name = unique_socket_name();
|
||||
let name_ref = name
|
||||
.clone()
|
||||
.to_ns_name::<GenericNamespaced>()
|
||||
.expect("valid name");
|
||||
let listener = ListenerOptions::new()
|
||||
.name(name_ref)
|
||||
.create_sync()
|
||||
.expect("listener");
|
||||
let client_name = name.clone();
|
||||
let client_thread = thread::spawn(move || {
|
||||
StreamTrait::connect(client_name.to_ns_name::<GenericNamespaced>().unwrap())
|
||||
.expect("connect")
|
||||
});
|
||||
let server = listener.accept().expect("accept");
|
||||
let client = client_thread.join().expect("client thread");
|
||||
(server, client)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_client_request_response_matching() {
|
||||
let (host_stream, mut runner_stream) = connect_pair();
|
||||
let client = V4Client::from_stream_for_test(host_stream);
|
||||
|
||||
let runner = thread::spawn(move || {
|
||||
let req = recv::<PluginToHostV4>(&mut runner_stream).unwrap();
|
||||
match req {
|
||||
PluginToHostV4::Request { id, payload: PluginRequest::PushInfo(s) } => {
|
||||
assert_eq!(s, "hello host");
|
||||
send(
|
||||
&mut runner_stream,
|
||||
&HostToPluginV4::Response {
|
||||
id,
|
||||
payload: PluginResponse::Ok,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
});
|
||||
|
||||
let mut api = client.plugin_host_api(0, Rc::new(RefCell::new(HashMap::new())));
|
||||
api.push_info("hello host");
|
||||
runner.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_client_add_entities_request_response_matching() {
|
||||
let (host_stream, mut runner_stream) = connect_pair();
|
||||
let client = V4Client::from_stream_for_test(host_stream);
|
||||
|
||||
let runner = thread::spawn(move || {
|
||||
let req = recv::<PluginToHostV4>(&mut runner_stream).unwrap();
|
||||
match req {
|
||||
PluginToHostV4::Request { id, payload: PluginRequest::AddEntities(v) } => {
|
||||
assert_eq!(v.len(), 2);
|
||||
send(
|
||||
&mut runner_stream,
|
||||
&HostToPluginV4::Response {
|
||||
id,
|
||||
payload: PluginResponse::Handles(vec![Handle::new(1), Handle::new(2)]),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
});
|
||||
|
||||
let mut api = client.plugin_host_api(0, Rc::new(RefCell::new(HashMap::new())));
|
||||
let handles = api.add_entities(vec![
|
||||
EntityType::Point(Point::new()),
|
||||
EntityType::Point(Point::new()),
|
||||
]);
|
||||
assert_eq!(handles, vec![Handle::new(1), Handle::new(2)]);
|
||||
runner.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_client_notification_queue_size_env_is_honored() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let prev = std::env::var("OCS_PLUGIN_NOTIFY_QUEUE_SIZE").ok();
|
||||
std::env::set_var("OCS_PLUGIN_NOTIFY_QUEUE_SIZE", "7");
|
||||
assert_eq!(notify_queue_size(), 7);
|
||||
match prev {
|
||||
Some(v) => std::env::set_var("OCS_PLUGIN_NOTIFY_QUEUE_SIZE", v),
|
||||
None => std::env::remove_var("OCS_PLUGIN_NOTIFY_QUEUE_SIZE"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_client_notification_queue_drops_newest_on_overflow() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let prev = std::env::var("OCS_PLUGIN_NOTIFY_QUEUE_SIZE").ok();
|
||||
std::env::set_var("OCS_PLUGIN_NOTIFY_QUEUE_SIZE", "1");
|
||||
let (host_stream, mut runner_stream) = connect_pair();
|
||||
let client = V4Client::from_stream_for_test(host_stream);
|
||||
|
||||
// Send two notifications; the bounded queue has capacity 1, so the
|
||||
// second one should be dropped.
|
||||
send(
|
||||
&mut runner_stream,
|
||||
&HostToPluginV4::Notification(NotificationEnvelope {
|
||||
command_id: Some(1),
|
||||
payload: HostNotification::Cancel,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
send(
|
||||
&mut runner_stream,
|
||||
&HostToPluginV4::Notification(NotificationEnvelope {
|
||||
command_id: Some(2),
|
||||
payload: HostNotification::Cancel,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let first = client.try_recv_notification();
|
||||
let second = client.try_recv_notification();
|
||||
assert_eq!(first.map(|(id, _)| id), Some(Some(1)));
|
||||
assert_eq!(second, None);
|
||||
|
||||
match prev {
|
||||
Some(v) => std::env::set_var("OCS_PLUGIN_NOTIFY_QUEUE_SIZE", v),
|
||||
None => std::env::remove_var("OCS_PLUGIN_NOTIFY_QUEUE_SIZE"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_code_responder_emits_expected_frame() {
|
||||
let (host_stream, mut runner_stream) = connect_pair();
|
||||
let client = V4Client::from_stream_for_test(host_stream);
|
||||
|
||||
let result = ExecutionResult {
|
||||
success: true,
|
||||
output: Some("42".to_string()),
|
||||
error: None,
|
||||
error_type: None,
|
||||
traceback: None,
|
||||
line_number: None,
|
||||
column_number: None,
|
||||
duration_ms: 1.0,
|
||||
};
|
||||
|
||||
let responder = client.execute_code_responder(7);
|
||||
responder(result);
|
||||
|
||||
let got = recv::<PluginToHostV4>(&mut runner_stream).unwrap();
|
||||
match got {
|
||||
PluginToHostV4::Response {
|
||||
id: 7,
|
||||
payload: HostResponse::CodeExecutionResult(r),
|
||||
} => {
|
||||
assert!(r.success);
|
||||
assert_eq!(r.output, Some("42".to_string()));
|
||||
}
|
||||
other => panic!("unexpected frame: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
//! V4 full-duplex notification protocol for out-of-process plugins.
|
||||
//!
|
||||
//! V4 keeps the existing V3 request/response vocabulary but multiplexes
|
||||
//! requests, responses, and best-effort notifications over a single local
|
||||
//! socket. The V3 files (`src/ipc/{protocol,client,server}.rs`) are left
|
||||
//! untouched; all V4 logic lives in this module.
|
||||
|
||||
pub mod client;
|
||||
pub mod protocol;
|
||||
pub mod server;
|
||||
@@ -0,0 +1,127 @@
|
||||
//! V4 wire frames and envelope types.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::host::{HostNotification, PluginNotification};
|
||||
use crate::ipc::protocol::{HostRequest, HostResponse, PluginRequest, PluginResponse};
|
||||
|
||||
/// Protocol version carried in the V4 handshake.
|
||||
pub const V4_PROTOCOL_VERSION: u32 = 4;
|
||||
|
||||
/// Best-effort notification envelope carrying an optional command correlation
|
||||
/// ID in addition to the typed payload.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotificationEnvelope<T> {
|
||||
pub command_id: Option<u64>,
|
||||
pub payload: T,
|
||||
}
|
||||
|
||||
/// Messages sent from the host to the plugin runner on the V4 socket.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum HostToPluginV4 {
|
||||
Request { id: u64, payload: HostRequest },
|
||||
Response { id: u64, payload: PluginResponse },
|
||||
Notification(NotificationEnvelope<HostNotification>),
|
||||
}
|
||||
|
||||
/// Messages sent from the plugin runner to the host on the V4 socket.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
pub enum PluginToHostV4 {
|
||||
Request { id: u64, payload: PluginRequest },
|
||||
Response { id: u64, payload: HostResponse },
|
||||
Notification(NotificationEnvelope<PluginNotification>),
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "host"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::host::LogLevel;
|
||||
|
||||
#[test]
|
||||
fn v4_frame_roundtrip() {
|
||||
let frame = HostToPluginV4::Notification(NotificationEnvelope {
|
||||
command_id: Some(7),
|
||||
payload: HostNotification::InputLine {
|
||||
line: "hello".to_string(),
|
||||
},
|
||||
});
|
||||
let bytes = bincode::serialize(&frame).unwrap();
|
||||
let got: HostToPluginV4 = bincode::deserialize(&bytes).unwrap();
|
||||
match got {
|
||||
HostToPluginV4::Notification(env) => {
|
||||
assert_eq!(env.command_id, Some(7));
|
||||
assert_eq!(
|
||||
env.payload,
|
||||
HostNotification::InputLine {
|
||||
line: "hello".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_notification_roundtrip() {
|
||||
let frame = PluginToHostV4::Notification(NotificationEnvelope {
|
||||
command_id: None,
|
||||
payload: PluginNotification::Log {
|
||||
level: LogLevel::Info,
|
||||
text: "hi".to_string(),
|
||||
},
|
||||
});
|
||||
let bytes = bincode::serialize(&frame).unwrap();
|
||||
let got: PluginToHostV4 = bincode::deserialize(&bytes).unwrap();
|
||||
match got {
|
||||
PluginToHostV4::Notification(env) => {
|
||||
assert_eq!(env.command_id, None);
|
||||
assert_eq!(
|
||||
env.payload,
|
||||
PluginNotification::Log {
|
||||
level: LogLevel::Info,
|
||||
text: "hi".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_notification_deserializes_as_unknown() {
|
||||
// Build a payload with an unknown discriminant (99) followed by some
|
||||
// bytes. The custom deserializer should return Unknown(raw_bytes).
|
||||
let mut payload = vec![99u8];
|
||||
bincode::serialize_into(&mut payload, &"future".to_string()).unwrap();
|
||||
let envelope = NotificationEnvelope {
|
||||
command_id: Some(1),
|
||||
payload: HostNotification::Unknown(payload.clone()),
|
||||
};
|
||||
let frame = HostToPluginV4::Notification(envelope);
|
||||
let bytes = bincode::serialize(&frame).unwrap();
|
||||
let got: HostToPluginV4 = bincode::deserialize(&bytes).unwrap();
|
||||
match got {
|
||||
HostToPluginV4::Notification(env) => match env.payload {
|
||||
HostNotification::Unknown(raw) => {
|
||||
assert_eq!(raw[0], 99);
|
||||
}
|
||||
other => panic!("expected Unknown, got {other:?}"),
|
||||
},
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v3_runner_handshake_token_roundtrips_after_tokenv4() {
|
||||
use crate::ipc::protocol::RunnerHandshake;
|
||||
let original = RunnerHandshake::Token("abc".to_string());
|
||||
let bytes = bincode::serialize(&original).unwrap();
|
||||
let got: RunnerHandshake = bincode::deserialize(&bytes).unwrap();
|
||||
match got {
|
||||
RunnerHandshake::Token(s) => assert_eq!(s, "abc"),
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Host-side V4 IPC reader and dispatcher helpers.
|
||||
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use interprocess::local_socket::Stream;
|
||||
|
||||
use crate::host::PluginNotification;
|
||||
use crate::ipc::transport::{recv, TransportError};
|
||||
use crate::ipc::v4::protocol::PluginToHostV4;
|
||||
|
||||
/// Default plugin→host notification rate limit (notifications per second).
|
||||
const DEFAULT_NOTIFY_RATE_LIMIT: f64 = 1000.0;
|
||||
|
||||
/// Minimum interval between verbose drop logs to avoid spam.
|
||||
const DROP_LOG_INTERVAL: Duration = Duration::from_secs(1);
|
||||
|
||||
macro_rules! vlog {
|
||||
($($arg:tt)*) => {{
|
||||
if crate::process::verbose() {
|
||||
eprintln!($($arg)*);
|
||||
}
|
||||
}};
|
||||
}
|
||||
|
||||
/// A frame delivered from the V4 reader thread to the host main thread.
|
||||
pub enum HostIncoming {
|
||||
Response { id: u64, payload: crate::ipc::protocol::HostResponse },
|
||||
Request { id: u64, payload: Box<crate::ipc::protocol::PluginRequest> },
|
||||
}
|
||||
|
||||
/// Token-bucket rate limiter.
|
||||
pub struct RateLimiter {
|
||||
rate: f64,
|
||||
tokens: f64,
|
||||
last: Instant,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
pub fn new(rate_per_second: f64) -> Self {
|
||||
Self {
|
||||
rate: rate_per_second.max(0.0),
|
||||
tokens: rate_per_second,
|
||||
last: Instant::now(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allow(&mut self) -> bool {
|
||||
if self.rate <= 0.0 {
|
||||
return false;
|
||||
}
|
||||
let now = Instant::now();
|
||||
let elapsed = now.duration_since(self.last).as_secs_f64();
|
||||
self.tokens = (self.tokens + elapsed * self.rate).min(self.rate);
|
||||
self.last = now;
|
||||
if self.tokens >= 1.0 {
|
||||
self.tokens -= 1.0;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn default_notify_rate_limit() -> f64 {
|
||||
std::env::var("OCS_PLUGIN_NOTIFY_RATE_LIMIT")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.map(|n: f64| n.max(0.0))
|
||||
.unwrap_or(DEFAULT_NOTIFY_RATE_LIMIT)
|
||||
}
|
||||
|
||||
/// State shared between the host main thread and the V4 reader thread.
|
||||
pub struct V4HostShared {
|
||||
pub writer: Mutex<Option<Stream>>,
|
||||
pub alive: AtomicBool,
|
||||
}
|
||||
|
||||
/// Run the host-side V4 reader thread until the socket closes or a fatal error
|
||||
/// occurs. Responses and requests are forwarded to `incoming`; notifications
|
||||
/// are dispatched immediately to `handler` subject to `rate_limiter`.
|
||||
pub fn run_host_reader_thread(
|
||||
mut reader: Stream,
|
||||
shared: Arc<V4HostShared>,
|
||||
incoming: mpsc::Sender<HostIncoming>,
|
||||
handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync>,
|
||||
mut rate_limiter: RateLimiter,
|
||||
) {
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let mut last_drop_log: Option<Instant> = None;
|
||||
loop {
|
||||
match recv::<PluginToHostV4>(&mut reader) {
|
||||
Ok(PluginToHostV4::Response { id, payload }) => {
|
||||
if incoming
|
||||
.send(HostIncoming::Response { id, payload })
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(PluginToHostV4::Notification(envelope)) => {
|
||||
if rate_limiter.allow() {
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
(handler)(envelope.command_id, envelope.payload)
|
||||
}));
|
||||
} else {
|
||||
let now = Instant::now();
|
||||
let should_log = last_drop_log
|
||||
.map(|t| now.duration_since(t) >= DROP_LOG_INTERVAL)
|
||||
.unwrap_or(true);
|
||||
if should_log && crate::process::verbose() {
|
||||
eprintln!(
|
||||
"[plugin] plugin→host notification rate limit exceeded; dropping"
|
||||
);
|
||||
last_drop_log = Some(now);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(PluginToHostV4::Request { id, payload }) => {
|
||||
if incoming
|
||||
.send(HostIncoming::Request { id, payload: Box::new(payload) })
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(TransportError::Disconnected) => {
|
||||
// Peer closed the connection; this is the normal shutdown
|
||||
// path, so don't log it as an error.
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
vlog!("[plugin] V4 host reader error: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}));
|
||||
shared.alive.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "host"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_allows_burst_then_throttles() {
|
||||
let mut limiter = RateLimiter::new(10.0);
|
||||
for _ in 0..10 {
|
||||
assert!(limiter.allow());
|
||||
}
|
||||
// Immediately after the burst we should be out of tokens.
|
||||
assert!(!limiter.allow());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rate_limiter_refills_over_time() {
|
||||
let mut limiter = RateLimiter::new(100.0);
|
||||
assert!(limiter.allow());
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
assert!(limiter.allow());
|
||||
}
|
||||
}
|
||||
@@ -13,16 +13,28 @@
|
||||
//! ([`RibbonGroup`], [`ToolDef`], …) a plugin uses to describe its tab.
|
||||
//!
|
||||
//! The runtime host surface a plugin uses at *dispatch* time (document access,
|
||||
//! command line, undo) is `acadrust`-typed and therefore lives in the host
|
||||
//! binary for now; see `docs/plugin-architecture.md` (phase 1b).
|
||||
//! command line, undo) is `acadrust`-typed and therefore lives in the `host`
|
||||
//! feature; it re-exports `acadrust` so plugins do not need to depend on it
|
||||
//! directly and risk an ABI mismatch from a different version.
|
||||
//!
|
||||
//! For binary compatibility, the host and every plugin must resolve the same
|
||||
//! `acadrust` source. The host does this via a `[patch.crates-io]` entry in
|
||||
//! `Cargo.toml`; out-of-tree plugins should copy that exact patch.
|
||||
|
||||
pub mod manifest;
|
||||
pub mod ribbon;
|
||||
pub mod type_registry;
|
||||
pub mod type_registry_types;
|
||||
pub mod version_info;
|
||||
|
||||
/// Runtime host surface — only built with the `host` feature (pulls `acadrust`).
|
||||
#[cfg(feature = "host")]
|
||||
pub mod host;
|
||||
|
||||
/// Host-side V4 snapshot manager — only built with the `host` feature.
|
||||
#[cfg(feature = "host")]
|
||||
pub mod host_v4;
|
||||
|
||||
/// Out-of-process plugin runtime — only built with the `host` feature.
|
||||
#[cfg(feature = "host")]
|
||||
pub mod ipc;
|
||||
@@ -42,9 +54,15 @@ pub mod shm;
|
||||
pub mod runner;
|
||||
|
||||
pub use manifest::{
|
||||
host_accepts_plugin_version, ApiVersion, PluginManifest, API_VERSION, API_VERSION_MIN_SUPPORTED,
|
||||
effective_max_api_version, host_accepts_plugin_version, ApiVersion, PluginManifest,
|
||||
API_VERSION, API_VERSION_MIN_SUPPORTED, MAX_API_VERSION_ENV,
|
||||
};
|
||||
pub use ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef};
|
||||
pub use type_registry::{
|
||||
get_embedded_type_registry_json, EnumVariantInfo, FieldInfo, MethodInfo, ParameterInfo,
|
||||
TypeId, TypeInfo, TypeKind, TypeRegistry,
|
||||
};
|
||||
pub use version_info::get_embedded_version_info_json;
|
||||
|
||||
#[cfg(feature = "host")]
|
||||
pub use process::{DispatchResult, PluginError, PluginManager, PluginProcess};
|
||||
@@ -4,14 +4,21 @@
|
||||
/// compatibility. v2 added `HostApi::start_interactive`. v3 changes
|
||||
/// `document()` / `document_mut()` to local cached copies for out-of-process
|
||||
/// plugins and appends `document_reader` / `document_view` at the end of the
|
||||
/// vtable so API v2 plugins keep working.
|
||||
pub const API_VERSION: u32 = 3;
|
||||
/// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on
|
||||
/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched.
|
||||
pub const API_VERSION: u32 = 4;
|
||||
|
||||
/// Oldest plugin API major the current host still loads. This keeps previously
|
||||
/// compiled cdylibs usable as long as their vtable layout is a prefix of the
|
||||
/// current `HostApi` trait.
|
||||
pub const API_VERSION_MIN_SUPPORTED: u32 = 2;
|
||||
|
||||
/// Environment variable that caps the API major accepted by the host at
|
||||
/// runtime. Set to `3` to disable V4 plugins while keeping V2/V3 plugins
|
||||
/// working; set to `2` for V2-only mode. The same mechanism will apply to
|
||||
/// future API majors.
|
||||
pub const MAX_API_VERSION_ENV: &str = "OCS_PLUGIN_MAX_API_VERSION";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct ApiVersion {
|
||||
pub major: u32,
|
||||
@@ -28,17 +35,33 @@ impl ApiVersion {
|
||||
}
|
||||
}
|
||||
|
||||
/// The effective maximum API major this host accepts, honoring
|
||||
/// [`MAX_API_VERSION_ENV`]. This lets operators disable a new API major at
|
||||
/// runtime without rebuilding.
|
||||
pub fn effective_max_api_version() -> u32 {
|
||||
std::env::var(MAX_API_VERSION_ENV)
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.map(|max: u32| max.clamp(API_VERSION_MIN_SUPPORTED, API_VERSION))
|
||||
.unwrap_or(API_VERSION)
|
||||
}
|
||||
|
||||
/// True when a plugin built against `plugin_major` can be loaded by this host.
|
||||
/// The host supports majors from `API_VERSION_MIN_SUPPORTED` up to
|
||||
/// `API_VERSION`.
|
||||
/// [`effective_max_api_version`], which can be lowered at runtime via
|
||||
/// `OCS_PLUGIN_MAX_API_VERSION`.
|
||||
pub fn host_accepts_plugin_version(plugin_major: u32) -> bool {
|
||||
plugin_major >= API_VERSION_MIN_SUPPORTED && plugin_major <= API_VERSION
|
||||
plugin_major >= API_VERSION_MIN_SUPPORTED && plugin_major <= effective_max_api_version()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Mutex;
|
||||
|
||||
use super::*;
|
||||
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
#[test]
|
||||
fn current_matches_const() {
|
||||
assert_eq!(ApiVersion::CURRENT.major, API_VERSION);
|
||||
@@ -61,6 +84,55 @@ mod tests {
|
||||
fn api_v2_plugin_runs_on_api_v3_host() {
|
||||
assert!(ApiVersion { major: 2 }.is_compatible_with(ApiVersion { major: 3 }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_max_defaults_to_api_version() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::remove_var(MAX_API_VERSION_ENV);
|
||||
assert_eq!(effective_max_api_version(), API_VERSION);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_max_can_disable_v4() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var(MAX_API_VERSION_ENV, "3");
|
||||
assert_eq!(effective_max_api_version(), 3);
|
||||
assert!(!host_accepts_plugin_version(4));
|
||||
assert!(host_accepts_plugin_version(3));
|
||||
assert!(host_accepts_plugin_version(2));
|
||||
std::env::remove_var(MAX_API_VERSION_ENV);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_max_v2_only_mode() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var(MAX_API_VERSION_ENV, "2");
|
||||
assert_eq!(effective_max_api_version(), 2);
|
||||
assert!(!host_accepts_plugin_version(4));
|
||||
assert!(!host_accepts_plugin_version(3));
|
||||
assert!(host_accepts_plugin_version(2));
|
||||
std::env::remove_var(MAX_API_VERSION_ENV);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn effective_max_clamps_to_supported_range() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var(MAX_API_VERSION_ENV, "99");
|
||||
assert_eq!(effective_max_api_version(), API_VERSION);
|
||||
std::env::set_var(MAX_API_VERSION_ENV, "1");
|
||||
assert_eq!(effective_max_api_version(), API_VERSION_MIN_SUPPORTED);
|
||||
std::env::remove_var(MAX_API_VERSION_ENV);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_accepts_supported_range() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::remove_var(MAX_API_VERSION_ENV);
|
||||
assert!(host_accepts_plugin_version(API_VERSION_MIN_SUPPORTED));
|
||||
assert!(host_accepts_plugin_version(API_VERSION));
|
||||
assert!(!host_accepts_plugin_version(API_VERSION + 1));
|
||||
assert!(!host_accepts_plugin_version(API_VERSION_MIN_SUPPORTED - 1));
|
||||
}
|
||||
}
|
||||
|
||||
/// Static metadata every plugin supplies at registration time.
|
||||
|
||||
@@ -10,19 +10,97 @@ use std::time::{Duration, Instant};
|
||||
use interprocess::local_socket::traits::Listener;
|
||||
use interprocess::local_socket::{GenericNamespaced, ListenerOptions, Stream, ToNsName};
|
||||
|
||||
use crate::host::{CommandStep, HostApi};
|
||||
use crate::host::{CommandSource, CommandStep, ExecutionResult, HostApi, HostNotification, PluginNotification};
|
||||
use crate::ipc::protocol::{
|
||||
HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, RunnerHandshake,
|
||||
PLUGIN_TOKEN_ENV,
|
||||
CadDocument, EntityType, Handle, HostRequest, HostResponse, HostToPlugin, InteractiveEvent,
|
||||
PluginToHost, RunnerHandshake, PLUGIN_TOKEN_ENV,
|
||||
};
|
||||
use crate::ipc::server::handle_plugin_request;
|
||||
use crate::ipc::transport::{recv, send};
|
||||
use crate::process::v4::V4Connection;
|
||||
use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup as OwnedRibbonGroupAlias};
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::sync::Arc;
|
||||
|
||||
mod manager;
|
||||
pub use manager::{DispatchResult, PluginManager};
|
||||
mod v4;
|
||||
pub use manager::{DispatchResult, NotificationHandler, PluginManager};
|
||||
|
||||
/// A dummy `HostApi` used for V4 paths that do not supply a real host surface
|
||||
/// (e.g., interactive events). Nested plugin requests receive safe defaults.
|
||||
struct NullHost;
|
||||
|
||||
impl HostApi for NullHost {
|
||||
fn tab_index(&self) -> usize {
|
||||
0
|
||||
}
|
||||
fn document(&self) -> &CadDocument {
|
||||
panic!("NullHost: document not available")
|
||||
}
|
||||
fn document_mut(&mut self) -> &mut CadDocument {
|
||||
panic!("NullHost: document_mut not available")
|
||||
}
|
||||
fn document_reader(&self) -> Box<dyn crate::host::DocumentReader + '_> {
|
||||
Box::new(NullReader)
|
||||
}
|
||||
fn add_entity(&mut self, _entity: EntityType) -> Handle {
|
||||
Handle::default()
|
||||
}
|
||||
fn bump_geometry(&mut self) {}
|
||||
fn read_record(
|
||||
&self,
|
||||
_handle: Handle,
|
||||
_app_name: &str,
|
||||
) -> Option<&crate::ipc::protocol::ExtendedDataRecord> {
|
||||
None
|
||||
}
|
||||
fn write_record(&mut self, _handle: Handle, _record: crate::ipc::protocol::ExtendedDataRecord) -> bool {
|
||||
false
|
||||
}
|
||||
fn remove_record(&mut self, _handle: 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!("NullHost: ensure_plugin_state_any not available")
|
||||
}
|
||||
}
|
||||
|
||||
struct NullReader;
|
||||
impl crate::host::DocumentReader for NullReader {
|
||||
fn entity_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
fn for_each_entity(&self, _f: &mut dyn FnMut(crate::host::ReaderEntity<'_>)) {}
|
||||
fn layer_name(&self, _handle: Handle) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
fn app_id_name(&self, _handle: Handle) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether verbose plugin-IPC logging is on. Off by default so a normal run
|
||||
/// only prints the one-line "Loaded plugin" notice; set `OCS_PLUGIN_VERBOSE=1`
|
||||
@@ -44,7 +122,7 @@ macro_rules! vlog {
|
||||
}
|
||||
|
||||
/// Maximum time to wait for the plugin runner to connect back to the host.
|
||||
const SPAWN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const SPAWN_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
fn spawn_timeout() -> Duration {
|
||||
std::env::var("OCS_PLUGIN_SPAWN_TIMEOUT_SECS")
|
||||
@@ -74,6 +152,16 @@ fn request_timeout(kind: &'static str) -> Duration {
|
||||
base_max_floor(call_timeout(), kind)
|
||||
}
|
||||
|
||||
fn execute_code_timeout() -> Duration {
|
||||
const DEFAULT: Duration = Duration::from_secs(60);
|
||||
std::env::var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(DEFAULT)
|
||||
.max(DEFAULT)
|
||||
}
|
||||
|
||||
fn base_max_floor(base: Duration, kind: &'static str) -> Duration {
|
||||
// Tests lower the floor via OCS_PLUGIN_TEST_FLOOR_SECS so the suite does not
|
||||
// wait out the real 10 s Dispatch minimum. The seam is compiled in only
|
||||
@@ -89,6 +177,7 @@ fn base_max_floor(base: Duration, kind: &'static str) -> Duration {
|
||||
"GetManifest" | "GetRibbon" => Duration::from_secs(5),
|
||||
"Dispatch" => Duration::from_secs(10),
|
||||
"InteractiveEvent" | "GetPrompt" | "NeedsEntityPick" => Duration::from_secs(2),
|
||||
"ExecuteCode" => execute_code_timeout(),
|
||||
_ => Duration::from_secs(1),
|
||||
};
|
||||
base.max(floor)
|
||||
@@ -102,6 +191,7 @@ fn request_kind(req: &HostRequest) -> &'static str {
|
||||
HostRequest::InteractiveEvent { .. } => "InteractiveEvent",
|
||||
HostRequest::GetPrompt { .. } => "GetPrompt",
|
||||
HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick",
|
||||
HostRequest::ExecuteCode { .. } => "ExecuteCode",
|
||||
HostRequest::Shutdown => "Shutdown",
|
||||
}
|
||||
}
|
||||
@@ -114,22 +204,24 @@ pub enum PluginError {
|
||||
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("call timeout: {request} did not respond within {duration:?}")]
|
||||
CallTimeout {
|
||||
request: &'static str,
|
||||
duration: Duration,
|
||||
},
|
||||
#[error("runner exited before connecting")]
|
||||
RunnerExited,
|
||||
#[error("runner exited unexpectedly during {request} ({status})")]
|
||||
RunnerCrashed {
|
||||
request: &'static str,
|
||||
status: String,
|
||||
},
|
||||
#[error("unexpected response: {0:?}")]
|
||||
UnexpectedResponse(HostResponse),
|
||||
UnexpectedResponse(Box<HostResponse>),
|
||||
}
|
||||
|
||||
/// One spawned plugin process.
|
||||
pub struct PluginProcess {
|
||||
stream: Mutex<Option<Stream>>,
|
||||
v4: Option<V4Connection>,
|
||||
child: Mutex<Option<Child>>,
|
||||
id: String,
|
||||
manifest: OwnedPluginManifest,
|
||||
@@ -138,7 +230,11 @@ pub struct PluginProcess {
|
||||
|
||||
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> {
|
||||
pub fn spawn(
|
||||
cdylib_path: &Path,
|
||||
host: &mut dyn HostApi,
|
||||
notification_handler: NotificationHandler,
|
||||
) -> Result<Self, PluginError> {
|
||||
let socket_name = generate_socket_name();
|
||||
let socket_name_ref: interprocess::local_socket::Name = socket_name
|
||||
.clone()
|
||||
@@ -156,15 +252,28 @@ impl PluginProcess {
|
||||
// Create the listener before spawning so the runner can connect immediately.
|
||||
let listener = ListenerOptions::new().name(socket_name_ref).create_sync()?;
|
||||
|
||||
let child = Command::new(&runner_path)
|
||||
let mut child = Command::new(&runner_path)
|
||||
.arg("--ocs-plugin-runner")
|
||||
.arg(&socket_name)
|
||||
.arg(cdylib_path)
|
||||
.env(PLUGIN_TOKEN_ENV, &token)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()?;
|
||||
let last_stderr: Arc<Mutex<String>> = Arc::new(Mutex::new(String::new()));
|
||||
if let Some(stderr) = child.stderr.take() {
|
||||
let last_stderr = Arc::clone(&last_stderr);
|
||||
std::thread::spawn(move || {
|
||||
use std::io::{BufRead, BufReader};
|
||||
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
|
||||
if let Ok(mut guard) = last_stderr.lock() {
|
||||
*guard = line.clone();
|
||||
}
|
||||
eprintln!("[runner stderr] {line}");
|
||||
}
|
||||
});
|
||||
}
|
||||
let child = Mutex::new(Some(child));
|
||||
|
||||
// Accept the runner connection with a timeout so a hung/crashed runner
|
||||
@@ -183,13 +292,21 @@ impl PluginProcess {
|
||||
if let Some(child) = child.lock().unwrap_or_else(|e| e.into_inner()).take() {
|
||||
reap(child);
|
||||
}
|
||||
return Err(PluginError::SpawnTimeout(spawn_timeout()));
|
||||
let status = spawn_failure_status(&child, &last_stderr);
|
||||
return Err(PluginError::RunnerCrashed {
|
||||
request: "spawn/accept",
|
||||
status,
|
||||
});
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
if let Some(child) = child.lock().unwrap_or_else(|e| e.into_inner()).take() {
|
||||
reap(child);
|
||||
}
|
||||
return Err(PluginError::RunnerExited);
|
||||
let status = spawn_failure_status(&child, &last_stderr);
|
||||
return Err(PluginError::RunnerCrashed {
|
||||
request: "spawn/accept",
|
||||
status,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -207,32 +324,69 @@ impl PluginProcess {
|
||||
handshake_timeout,
|
||||
"Handshake",
|
||||
)?;
|
||||
if let Err(e) = verify_runner_handshake(handshake, &token) {
|
||||
mark_dead(&stream, &child);
|
||||
return Err(e);
|
||||
let is_v4 = match verify_runner_handshake(handshake, &token) {
|
||||
Ok(v4) => v4,
|
||||
Err(e) => {
|
||||
mark_dead(&stream, &child);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
|
||||
let no_op = &mut |_| {};
|
||||
let manifest;
|
||||
let ribbon;
|
||||
let v4: Option<V4Connection>;
|
||||
if is_v4 {
|
||||
// V4 path: split the stream into reader/writer halves and start
|
||||
// the V4 reader thread. The notification handler receives an empty
|
||||
// plugin id until we read the manifest below; that brief window is
|
||||
// fine because the plugin has not yet been asked to do work.
|
||||
let stream = stream.lock().unwrap_or_else(|e| e.into_inner()).take().expect("V4 stream");
|
||||
let plugin_id_placeholder = Arc::new(std::sync::Mutex::new(String::new()));
|
||||
let handler_for_v4: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> = {
|
||||
let plugin_id = Arc::clone(&plugin_id_placeholder);
|
||||
let base_handler = Arc::clone(¬ification_handler);
|
||||
Arc::new(move |command_id, notification| {
|
||||
let id = plugin_id.lock().unwrap_or_else(|e| e.into_inner()).clone();
|
||||
base_handler(&id, command_id, notification);
|
||||
})
|
||||
};
|
||||
let v4_conn = V4Connection::new(stream, handler_for_v4)?;
|
||||
manifest = match v4_conn.call(host, HostRequest::GetManifest, no_op)? {
|
||||
HostResponse::Manifest(m) => m,
|
||||
other => return Err(PluginError::UnexpectedResponse(Box::new(other))),
|
||||
};
|
||||
*plugin_id_placeholder.lock().unwrap_or_else(|e| e.into_inner()) = manifest.id.clone();
|
||||
ribbon = match v4_conn.call(host, HostRequest::GetRibbon, no_op)? {
|
||||
HostResponse::Ribbon(r) => r,
|
||||
other => return Err(PluginError::UnexpectedResponse(Box::new(other))),
|
||||
};
|
||||
v4 = Some(v4_conn);
|
||||
} else {
|
||||
// The runner first answers GetManifest and GetRibbon so the host can
|
||||
// build the UI without keeping the plugin object alive.
|
||||
manifest = match call(&stream, &child, host, HostRequest::GetManifest, no_op)? {
|
||||
HostResponse::Manifest(m) => m,
|
||||
other => return Err(PluginError::UnexpectedResponse(Box::new(other))),
|
||||
};
|
||||
ribbon = match call(&stream, &child, host, HostRequest::GetRibbon, no_op)? {
|
||||
HostResponse::Ribbon(r) => r,
|
||||
other => return Err(PluginError::UnexpectedResponse(Box::new(other))),
|
||||
};
|
||||
v4 = None;
|
||||
}
|
||||
|
||||
// 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, &child, host, HostRequest::GetManifest, no_op)? {
|
||||
HostResponse::Manifest(m) => m,
|
||||
other => return Err(PluginError::UnexpectedResponse(other)),
|
||||
};
|
||||
// Normal-run notice: just the loaded plugin's name (id + version). The
|
||||
// full IPC trace above/below is gated behind OCS_PLUGIN_VERBOSE.
|
||||
eprintln!(
|
||||
"Loaded plugin: {} ({} {})",
|
||||
manifest.name, manifest.id, manifest.version
|
||||
);
|
||||
let ribbon = match call(&stream, &child, host, HostRequest::GetRibbon, no_op)? {
|
||||
HostResponse::Ribbon(r) => r,
|
||||
other => return Err(PluginError::UnexpectedResponse(other)),
|
||||
};
|
||||
|
||||
let id = manifest.id.clone();
|
||||
Ok(Self {
|
||||
stream,
|
||||
v4,
|
||||
child,
|
||||
id,
|
||||
manifest,
|
||||
@@ -259,22 +413,52 @@ impl PluginProcess {
|
||||
on_start_interactive: &mut dyn FnMut(u64),
|
||||
) -> Result<bool, PluginError> {
|
||||
vlog!("[plugin] dispatching {cmd}");
|
||||
let result = match call(
|
||||
&self.stream,
|
||||
&self.child,
|
||||
host,
|
||||
HostRequest::Dispatch {
|
||||
cmd: cmd.to_string(),
|
||||
},
|
||||
on_start_interactive,
|
||||
)? {
|
||||
HostResponse::Bool(b) => Ok(b),
|
||||
other => Err(PluginError::UnexpectedResponse(other)),
|
||||
let result = if let Some(v4) = &self.v4 {
|
||||
match v4.call(
|
||||
host,
|
||||
HostRequest::Dispatch {
|
||||
cmd: cmd.to_string(),
|
||||
},
|
||||
on_start_interactive,
|
||||
)? {
|
||||
HostResponse::Bool(b) => Ok(b),
|
||||
other => Err(PluginError::UnexpectedResponse(Box::new(other))),
|
||||
}
|
||||
} else {
|
||||
match call(
|
||||
&self.stream,
|
||||
&self.child,
|
||||
host,
|
||||
HostRequest::Dispatch {
|
||||
cmd: cmd.to_string(),
|
||||
},
|
||||
on_start_interactive,
|
||||
)? {
|
||||
HostResponse::Bool(b) => Ok(b),
|
||||
other => Err(PluginError::UnexpectedResponse(Box::new(other))),
|
||||
}
|
||||
};
|
||||
vlog!("[plugin] dispatch {cmd} result: {result:?}");
|
||||
result
|
||||
}
|
||||
|
||||
/// Execute a REPL code snippet on a V4 plugin. The session is tied to the
|
||||
/// active document tab via `host.tab_index()`.
|
||||
pub fn execute_code(
|
||||
&self,
|
||||
host: &mut dyn HostApi,
|
||||
command_id: u64,
|
||||
source: CommandSource,
|
||||
code: &str,
|
||||
) -> Result<ExecutionResult, PluginError> {
|
||||
match &self.v4 {
|
||||
Some(v4) => v4.execute_code(host, command_id, source, code),
|
||||
None => Err(PluginError::Runner(
|
||||
"ExecuteCode requires V4 protocol".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
@@ -283,27 +467,39 @@ impl PluginProcess {
|
||||
command_id: u64,
|
||||
event: InteractiveEvent,
|
||||
) -> Result<CommandStep, PluginError> {
|
||||
self.send_request(HostRequest::InteractiveEvent { command_id, event })?;
|
||||
let kind = "InteractiveEvent";
|
||||
let timeout = request_timeout(kind);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match recv_with_deadline::<PluginToHost>(
|
||||
&self.stream,
|
||||
&self.child,
|
||||
deadline,
|
||||
timeout,
|
||||
kind,
|
||||
)? {
|
||||
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:?}"
|
||||
));
|
||||
self.send_response(resp)?;
|
||||
if let Some(v4) = &self.v4 {
|
||||
let resp = v4.call(
|
||||
&mut NullHost,
|
||||
HostRequest::InteractiveEvent { command_id, event },
|
||||
&mut |_| {},
|
||||
)?;
|
||||
match resp {
|
||||
HostResponse::CommandStep(s) => Ok(*s),
|
||||
other => Err(PluginError::UnexpectedResponse(Box::new(other))),
|
||||
}
|
||||
} else {
|
||||
self.send_request(HostRequest::InteractiveEvent { command_id, event })?;
|
||||
let kind = "InteractiveEvent";
|
||||
let timeout = request_timeout(kind);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match recv_with_deadline::<PluginToHost>(
|
||||
&self.stream,
|
||||
&self.child,
|
||||
deadline,
|
||||
timeout,
|
||||
kind,
|
||||
)? {
|
||||
PluginToHost::Response(HostResponse::CommandStep(s)) => return Ok(*s),
|
||||
PluginToHost::Response(other) => {
|
||||
return Err(PluginError::UnexpectedResponse(Box::new(other)))
|
||||
}
|
||||
PluginToHost::Request(req) => {
|
||||
let resp = crate::ipc::protocol::PluginResponse::Error(format!(
|
||||
"unexpected nested request during interactive event: {req:?}"
|
||||
));
|
||||
self.send_response(resp)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -311,27 +507,39 @@ impl PluginProcess {
|
||||
|
||||
/// Ask the plugin process for the current prompt of an interactive command.
|
||||
pub fn get_prompt(&self, command_id: u64) -> Result<String, PluginError> {
|
||||
self.send_request(HostRequest::GetPrompt { command_id })?;
|
||||
let kind = "GetPrompt";
|
||||
let timeout = request_timeout(kind);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match recv_with_deadline::<PluginToHost>(
|
||||
&self.stream,
|
||||
&self.child,
|
||||
deadline,
|
||||
timeout,
|
||||
kind,
|
||||
)? {
|
||||
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:?}"
|
||||
));
|
||||
self.send_response(resp)?;
|
||||
if let Some(v4) = &self.v4 {
|
||||
let resp = v4.call(
|
||||
&mut NullHost,
|
||||
HostRequest::GetPrompt { command_id },
|
||||
&mut |_| {},
|
||||
)?;
|
||||
match resp {
|
||||
HostResponse::Text(s) => Ok(s),
|
||||
other => Err(PluginError::UnexpectedResponse(Box::new(other))),
|
||||
}
|
||||
} else {
|
||||
self.send_request(HostRequest::GetPrompt { command_id })?;
|
||||
let kind = "GetPrompt";
|
||||
let timeout = request_timeout(kind);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match recv_with_deadline::<PluginToHost>(
|
||||
&self.stream,
|
||||
&self.child,
|
||||
deadline,
|
||||
timeout,
|
||||
kind,
|
||||
)? {
|
||||
PluginToHost::Response(HostResponse::Text(s)) => return Ok(s),
|
||||
PluginToHost::Response(other) => {
|
||||
return Err(PluginError::UnexpectedResponse(Box::new(other)))
|
||||
}
|
||||
PluginToHost::Request(req) => {
|
||||
let resp = crate::ipc::protocol::PluginResponse::Error(format!(
|
||||
"unexpected nested request during get_prompt: {req:?}"
|
||||
));
|
||||
self.send_response(resp)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -339,33 +547,48 @@ impl PluginProcess {
|
||||
|
||||
/// Ask the plugin process whether an interactive command wants object picks.
|
||||
pub fn needs_entity_pick(&self, command_id: u64) -> Result<bool, PluginError> {
|
||||
self.send_request(HostRequest::NeedsEntityPick { command_id })?;
|
||||
let kind = "NeedsEntityPick";
|
||||
let timeout = request_timeout(kind);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match recv_with_deadline::<PluginToHost>(
|
||||
&self.stream,
|
||||
&self.child,
|
||||
deadline,
|
||||
timeout,
|
||||
kind,
|
||||
)? {
|
||||
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:?}"
|
||||
));
|
||||
self.send_response(resp)?;
|
||||
if let Some(v4) = &self.v4 {
|
||||
let resp = v4.call(
|
||||
&mut NullHost,
|
||||
HostRequest::NeedsEntityPick { command_id },
|
||||
&mut |_| {},
|
||||
)?;
|
||||
match resp {
|
||||
HostResponse::Bool(b) => Ok(b),
|
||||
other => Err(PluginError::UnexpectedResponse(Box::new(other))),
|
||||
}
|
||||
} else {
|
||||
self.send_request(HostRequest::NeedsEntityPick { command_id })?;
|
||||
let kind = "NeedsEntityPick";
|
||||
let timeout = request_timeout(kind);
|
||||
let deadline = Instant::now() + timeout;
|
||||
loop {
|
||||
match recv_with_deadline::<PluginToHost>(
|
||||
&self.stream,
|
||||
&self.child,
|
||||
deadline,
|
||||
timeout,
|
||||
kind,
|
||||
)? {
|
||||
PluginToHost::Response(HostResponse::Bool(b)) => return Ok(b),
|
||||
PluginToHost::Response(other) => {
|
||||
return Err(PluginError::UnexpectedResponse(Box::new(other)))
|
||||
}
|
||||
PluginToHost::Request(req) => {
|
||||
let resp = crate::ipc::protocol::PluginResponse::Error(format!(
|
||||
"unexpected nested request during needs_entity_pick: {req:?}"
|
||||
));
|
||||
self.send_response(resp)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_alive(&self) -> bool {
|
||||
if let Some(v4) = &self.v4 {
|
||||
return v4.is_alive();
|
||||
}
|
||||
let mut guard = self.child.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match guard.as_mut() {
|
||||
Some(child) => match child.try_wait() {
|
||||
@@ -376,10 +599,46 @@ impl PluginProcess {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tear down the plugin process without blocking the caller. The stream is
|
||||
/// closed and the child is killed synchronously; the blocking `wait()` is
|
||||
/// done in a detached background thread so the host never waits on a plugin.
|
||||
/// Send a best-effort host-to-plugin notification. Requires V4; on V3
|
||||
/// processes this returns an error.
|
||||
pub fn notify_plugin(
|
||||
&self,
|
||||
command_id: Option<u64>,
|
||||
notification: HostNotification,
|
||||
) -> Result<(), PluginError> {
|
||||
if let Some(v4) = &self.v4 {
|
||||
v4.notify_plugin(command_id, notification)
|
||||
} else {
|
||||
Err(PluginError::Runner(
|
||||
"notify_plugin requires V4 protocol".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain any plugin→host requests that have arrived asynchronously for
|
||||
/// this process. On V3 processes this is a no-op.
|
||||
pub fn drain_requests(
|
||||
&self,
|
||||
host: &mut dyn HostApi,
|
||||
on_start_interactive: &mut dyn FnMut(u64),
|
||||
) -> Result<(), PluginError> {
|
||||
if let Some(v4) = &self.v4 {
|
||||
v4.drain_requests(host, on_start_interactive)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tear down the plugin process without blocking the caller. The child is
|
||||
/// killed first so the socket closes, which unblocks the V4 reader thread
|
||||
/// join. The stream is dropped and the blocking `wait()` is done in a
|
||||
/// detached background thread so the host never waits on a plugin.
|
||||
pub fn shutdown(&self) {
|
||||
// For V4 connections, request a graceful shutdown first so the plugin
|
||||
// can tear down any child processes it created. The V4 shutdown waits
|
||||
// a bounded amount of time for the runner to close its side.
|
||||
if let Some(v4) = &self.v4 {
|
||||
v4.shutdown();
|
||||
}
|
||||
let (stream, child) = self.take_resources();
|
||||
drop(stream);
|
||||
if let Some(child) = child {
|
||||
@@ -412,7 +671,7 @@ impl PluginProcess {
|
||||
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)
|
||||
send(stream, &HostToPlugin::Response(Box::new(resp))).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,6 +708,42 @@ fn mark_dead(stream: &Mutex<Option<Stream>>, child: &Mutex<Option<Child>>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a child process has already exited, returning its status if so.
|
||||
fn child_status(child: &Mutex<Option<Child>>) -> Option<std::process::ExitStatus> {
|
||||
let mut guard = child.lock().unwrap_or_else(|e| e.into_inner());
|
||||
match guard.as_mut() {
|
||||
Some(c) => c.try_wait().ok().flatten(),
|
||||
None => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Human-readable description of a process exit status.
|
||||
fn format_exit_status(status: Option<std::process::ExitStatus>) -> String {
|
||||
match status {
|
||||
Some(s) if s.success() => "exited successfully".to_string(),
|
||||
Some(s) => match s.code() {
|
||||
Some(code) => format!("exit code {code}"),
|
||||
None => "terminated by signal".to_string(),
|
||||
},
|
||||
None => "unknown status".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Describe why a runner failed before/during connection, including any stderr
|
||||
/// it produced.
|
||||
fn spawn_failure_status(
|
||||
child: &Mutex<Option<Child>>,
|
||||
last_stderr: &Mutex<String>,
|
||||
) -> String {
|
||||
let mut parts = vec![format_exit_status(child_status(child))];
|
||||
if let Ok(line) = last_stderr.lock() {
|
||||
if !line.is_empty() {
|
||||
parts.push(format!("stderr: {line}"));
|
||||
}
|
||||
}
|
||||
parts.join("; ")
|
||||
}
|
||||
|
||||
/// Receive one message from the plugin runner with a deadline.
|
||||
///
|
||||
/// A short-lived reader thread performs the blocking `recv` so that the main
|
||||
@@ -492,16 +787,43 @@ fn recv_with_deadline<T: DeserializeOwned + Send + 'static>(
|
||||
}
|
||||
Ok((Err(e), stream_opt)) => {
|
||||
*stream.lock().unwrap_or_else(|e| e.into_inner()) = stream_opt;
|
||||
let status = child_status(child);
|
||||
let connection_error = matches!(e, PluginError::Transport(_) | PluginError::Io(_));
|
||||
if connection_error || status.is_some() {
|
||||
let status_text = match status {
|
||||
Some(status) => format_exit_status(Some(status)),
|
||||
None => "connection lost before response; the plugin may have crashed or be ABI-incompatible".to_string(),
|
||||
};
|
||||
return Err(PluginError::RunnerCrashed {
|
||||
request,
|
||||
status: format!("{status_text}; cause: {e}"),
|
||||
});
|
||||
}
|
||||
Err(e)
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
let status = child_status(child);
|
||||
mark_dead(stream, child);
|
||||
if let Some(status) = status {
|
||||
return Err(PluginError::RunnerCrashed {
|
||||
request,
|
||||
status: format_exit_status(Some(status)),
|
||||
});
|
||||
}
|
||||
Err(PluginError::CallTimeout {
|
||||
request,
|
||||
duration: timeout,
|
||||
})
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => Err(shutdown_error()),
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
if let Some(status) = child_status(child) {
|
||||
return Err(PluginError::RunnerCrashed {
|
||||
request,
|
||||
status: format_exit_status(Some(status)),
|
||||
});
|
||||
}
|
||||
Err(shutdown_error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,11 +851,11 @@ fn call(
|
||||
match msg {
|
||||
PluginToHost::Response(resp) => return Ok(resp),
|
||||
PluginToHost::Request(plugin_req) => {
|
||||
let resp = handle_plugin_request(host, plugin_req, on_start_interactive);
|
||||
let resp = handle_plugin_request(host, *plugin_req, on_start_interactive);
|
||||
vlog!("[plugin] host -> runner response: {resp:?}");
|
||||
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))?;
|
||||
send(stream, &HostToPlugin::Response(Box::new(resp)))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -566,6 +888,11 @@ fn runner_executable() -> Result<PathBuf, PluginError> {
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
// The host spawns itself in runner mode. We used to create a hard link
|
||||
// with a distinct name so task managers show plugin processes
|
||||
// separately, but re-creating that link on every launch triggers
|
||||
// antivirus scanners on some systems and makes the first plugin spawn
|
||||
// time out. Spawning the host executable directly avoids that.
|
||||
let host = std::env::current_exe()?;
|
||||
if !host.exists() {
|
||||
return Err(PluginError::Runner(format!(
|
||||
@@ -573,17 +900,7 @@ fn runner_executable() -> Result<PathBuf, PluginError> {
|
||||
host.display()
|
||||
)));
|
||||
}
|
||||
|
||||
// Create a hard link with a distinct name next to the host binary. This
|
||||
// makes runner processes visible as separate sub-processes in task
|
||||
// managers / ps, while keeping the runner the exact same binary as the
|
||||
// host so they can never drift out of sync.
|
||||
let runner = distinct_runner_path(&host);
|
||||
let _ = std::fs::remove_file(&runner);
|
||||
match std::fs::hard_link(&host, &runner) {
|
||||
Ok(()) => runner,
|
||||
Err(_) => host,
|
||||
}
|
||||
host
|
||||
};
|
||||
|
||||
*cached = Some(path.clone());
|
||||
@@ -593,6 +910,7 @@ fn runner_executable() -> Result<PathBuf, PluginError> {
|
||||
/// Build a runner path like `<host>-plugin-runner<ext>` in the same directory.
|
||||
/// Using a distinct image name lets task managers show plugin processes as
|
||||
/// children/sub-processes of the host instead of collapsing them into one row.
|
||||
#[allow(dead_code)]
|
||||
fn distinct_runner_path(host: &Path) -> PathBuf {
|
||||
let mut runner = host.as_os_str().to_owned();
|
||||
if let Some(ext) = host.extension().and_then(|s| s.to_str()) {
|
||||
@@ -611,16 +929,36 @@ fn distinct_runner_path(host: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
/// Verify that the runner's `handshake` presents `expected_token`.
|
||||
///
|
||||
/// Returns `Ok(true)` for a V4 handshake, `Ok(false)` for a V3 handshake, and
|
||||
/// `Err` on token mismatch or unsupported protocol version.
|
||||
fn verify_runner_handshake(
|
||||
handshake: RunnerHandshake,
|
||||
expected_token: &str,
|
||||
) -> Result<(), PluginError> {
|
||||
) -> Result<bool, PluginError> {
|
||||
match handshake {
|
||||
RunnerHandshake::Token(ref presented) if presented == expected_token => {
|
||||
vlog!("[plugin] runner authenticated");
|
||||
Ok(())
|
||||
vlog!("[plugin] runner authenticated (V3)");
|
||||
Ok(false)
|
||||
}
|
||||
RunnerHandshake::Token(_) => Err(PluginError::Runner("authentication failed".into())),
|
||||
RunnerHandshake::TokenV4 {
|
||||
token: ref presented,
|
||||
protocol_version,
|
||||
} if presented == expected_token => {
|
||||
if protocol_version != 4 {
|
||||
return Err(PluginError::Runner(format!(
|
||||
"unsupported V4 protocol version {protocol_version}"
|
||||
)));
|
||||
}
|
||||
if crate::effective_max_api_version() < 4 {
|
||||
return Err(PluginError::Runner(
|
||||
"V4 protocol is disabled by OCS_PLUGIN_MAX_API_VERSION".into(),
|
||||
));
|
||||
}
|
||||
vlog!("[plugin] runner authenticated (V4)");
|
||||
Ok(true)
|
||||
}
|
||||
_ => Err(PluginError::Runner("authentication failed".into())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -821,6 +1159,7 @@ mod timeout_tests {
|
||||
let (host_stream, runner_stream) = connected_pair();
|
||||
let process = PluginProcess {
|
||||
stream: Mutex::new(Some(host_stream)),
|
||||
v4: None,
|
||||
child: Mutex::new(Some(sleepy_child())),
|
||||
id: "test.plugin".to_string(),
|
||||
manifest: fake_manifest(),
|
||||
@@ -906,11 +1245,11 @@ mod timeout_tests {
|
||||
);
|
||||
send(
|
||||
&mut peer,
|
||||
&PluginToHost::Request(PluginRequest::PushInfo("hello".to_string())),
|
||||
&PluginToHost::Request(Box::new(PluginRequest::PushInfo("hello".to_string()))),
|
||||
)
|
||||
.expect("send nested request");
|
||||
let resp = recv::<HostToPlugin>(&mut peer).expect("read nested response");
|
||||
assert!(matches!(resp, HostToPlugin::Response(PluginResponse::Ok)));
|
||||
assert!(matches!(resp, HostToPlugin::Response(ref r) if matches!(**r, PluginResponse::Ok)));
|
||||
send(&mut peer, &PluginToHost::Response(HostResponse::Bool(true)))
|
||||
.expect("send final response");
|
||||
});
|
||||
@@ -953,10 +1292,63 @@ mod timeout_tests {
|
||||
|
||||
#[test]
|
||||
fn runner_handshake_correct_token_is_accepted() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::remove_var("OCS_PLUGIN_MAX_API_VERSION");
|
||||
let result = verify_runner_handshake(
|
||||
RunnerHandshake::Token("correct-token".to_string()),
|
||||
"correct-token",
|
||||
);
|
||||
assert!(result.is_ok(), "expected authentication success, got {result:?}");
|
||||
assert!(!result.unwrap(), "V3 token should select V3 path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_handshake_tokenv4_selects_v4_path() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::remove_var("OCS_PLUGIN_MAX_API_VERSION");
|
||||
let result = verify_runner_handshake(
|
||||
RunnerHandshake::TokenV4 {
|
||||
token: "correct-token".to_string(),
|
||||
protocol_version: 4,
|
||||
},
|
||||
"correct-token",
|
||||
);
|
||||
assert!(result.is_ok(), "expected authentication success, got {result:?}");
|
||||
assert!(result.unwrap(), "TokenV4 should select V4 path");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_handshake_tokenv4_mismatched_version_is_rejected() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::remove_var("OCS_PLUGIN_MAX_API_VERSION");
|
||||
let result = verify_runner_handshake(
|
||||
RunnerHandshake::TokenV4 {
|
||||
token: "correct-token".to_string(),
|
||||
protocol_version: 5,
|
||||
},
|
||||
"correct-token",
|
||||
);
|
||||
assert!(
|
||||
matches!(result, Err(PluginError::Runner(ref s)) if s.contains("unsupported")),
|
||||
"expected unsupported version error, got {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runner_handshake_tokenv4_rejected_when_v4_disabled() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::set_var("OCS_PLUGIN_MAX_API_VERSION", "3");
|
||||
let result = verify_runner_handshake(
|
||||
RunnerHandshake::TokenV4 {
|
||||
token: "correct-token".to_string(),
|
||||
protocol_version: 4,
|
||||
},
|
||||
"correct-token",
|
||||
);
|
||||
assert!(
|
||||
matches!(result, Err(PluginError::Runner(ref s)) if s.contains("disabled")),
|
||||
"expected V4 disabled error, got {result:?}"
|
||||
);
|
||||
std::env::remove_var("OCS_PLUGIN_MAX_API_VERSION");
|
||||
}
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
//! Process manager for out-of-process plugins.
|
||||
|
||||
use std::path::Path;
|
||||
use std::process::Child;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::host::HostApi;
|
||||
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 {
|
||||
@@ -42,9 +45,18 @@ impl PluginManager {
|
||||
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(
|
||||
@@ -52,7 +64,11 @@ impl PluginManager {
|
||||
cdylib_path: &Path,
|
||||
host: &mut dyn HostApi,
|
||||
) -> Result<String, PluginError> {
|
||||
let process = PluginProcess::spawn(cdylib_path, host)?;
|
||||
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());
|
||||
@@ -63,6 +79,26 @@ impl PluginManager {
|
||||
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 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,
|
||||
@@ -140,6 +176,23 @@ impl PluginManager {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin asynchronous shutdown of every plugin process.
|
||||
///
|
||||
/// Kills every child synchronously on the calling thread and moves the
|
||||
@@ -147,21 +200,8 @@ impl PluginManager {
|
||||
/// shutdown is fast regardless of how many plugins are loaded.
|
||||
pub fn shutdown_all(&mut self) {
|
||||
let plugins = std::mem::take(&mut self.plugins);
|
||||
let mut children: Vec<Child> = Vec::with_capacity(plugins.len());
|
||||
for p in plugins {
|
||||
let (stream, child) = p.process.take_resources();
|
||||
drop(stream);
|
||||
if let Some(mut child) = child {
|
||||
let _ = child.kill();
|
||||
children.push(child);
|
||||
}
|
||||
}
|
||||
if !children.is_empty() {
|
||||
std::thread::spawn(move || {
|
||||
for mut child in children {
|
||||
let _ = child.wait();
|
||||
}
|
||||
});
|
||||
p.process.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,743 @@
|
||||
//! Private V4 connection owned by [`PluginProcess`](crate::process::PluginProcess).
|
||||
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{mpsc, Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use interprocess::local_socket::Stream;
|
||||
use interprocess::TryClone;
|
||||
|
||||
use crate::host::{CommandSource, ExecutionResult, HostApi, HostNotification, PluginNotification};
|
||||
use crate::ipc::protocol::{HostRequest, HostResponse};
|
||||
use crate::ipc::server::handle_plugin_request;
|
||||
use crate::ipc::transport::send;
|
||||
use crate::ipc::v4::protocol::{HostToPluginV4, NotificationEnvelope};
|
||||
use crate::ipc::v4::server::{
|
||||
default_notify_rate_limit, run_host_reader_thread, HostIncoming, RateLimiter, V4HostShared,
|
||||
};
|
||||
use crate::process::PluginError;
|
||||
|
||||
/// Default maximum time to wait for a plugin call to respond.
|
||||
const CALL_TIMEOUT_DEFAULT: Duration = Duration::from_secs(30);
|
||||
|
||||
fn call_timeout() -> Duration {
|
||||
std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(CALL_TIMEOUT_DEFAULT)
|
||||
}
|
||||
|
||||
/// Per-request-kind timeout floors.
|
||||
fn request_timeout(kind: &'static str) -> Duration {
|
||||
base_max_floor(call_timeout(), kind)
|
||||
}
|
||||
|
||||
fn execute_code_timeout() -> Duration {
|
||||
const DEFAULT: Duration = Duration::from_secs(60);
|
||||
std::env::var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.map(Duration::from_secs)
|
||||
.unwrap_or(DEFAULT)
|
||||
.max(DEFAULT)
|
||||
}
|
||||
|
||||
fn base_max_floor(base: Duration, kind: &'static str) -> Duration {
|
||||
#[cfg(test)]
|
||||
if let Some(secs) = std::env::var("OCS_PLUGIN_TEST_FLOOR_SECS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
{
|
||||
return base.max(Duration::from_secs(secs));
|
||||
}
|
||||
let floor = match kind {
|
||||
"GetManifest" | "GetRibbon" => Duration::from_secs(5),
|
||||
"Dispatch" => Duration::from_secs(10),
|
||||
"InteractiveEvent" | "GetPrompt" | "NeedsEntityPick" => Duration::from_secs(2),
|
||||
"ExecuteCode" => execute_code_timeout(),
|
||||
_ => Duration::from_secs(1),
|
||||
};
|
||||
base.max(floor)
|
||||
}
|
||||
|
||||
fn request_kind(req: &HostRequest) -> &'static str {
|
||||
match req {
|
||||
HostRequest::GetManifest => "GetManifest",
|
||||
HostRequest::GetRibbon => "GetRibbon",
|
||||
HostRequest::Dispatch { .. } => "Dispatch",
|
||||
HostRequest::InteractiveEvent { .. } => "InteractiveEvent",
|
||||
HostRequest::GetPrompt { .. } => "GetPrompt",
|
||||
HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick",
|
||||
HostRequest::ExecuteCode { .. } => "ExecuteCode",
|
||||
HostRequest::Shutdown => "Shutdown",
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct V4Connection {
|
||||
shared: Arc<V4HostShared>,
|
||||
incoming: Mutex<mpsc::Receiver<HostIncoming>>,
|
||||
next_id: AtomicU64,
|
||||
reader_handle: Mutex<Option<std::thread::JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl V4Connection {
|
||||
/// Wrap a freshly accepted V4 stream. The stream is cloned so the reader
|
||||
/// thread owns one half and the writer half stays on the calling thread.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn new(
|
||||
stream: Stream,
|
||||
handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync>,
|
||||
) -> Result<Self, PluginError> {
|
||||
let reader = stream.try_clone().map_err(|e| {
|
||||
PluginError::Io(std::io::Error::other(format!(
|
||||
"V4 stream try_clone failed: {e}"
|
||||
)))
|
||||
})?;
|
||||
|
||||
let shared = Arc::new(V4HostShared {
|
||||
writer: Mutex::new(Some(stream)),
|
||||
alive: AtomicBool::new(true),
|
||||
});
|
||||
|
||||
let (incoming_tx, incoming_rx) = mpsc::channel();
|
||||
let rate_limiter = RateLimiter::new(default_notify_rate_limit());
|
||||
|
||||
let shared_for_thread = Arc::clone(&shared);
|
||||
let handle = std::thread::spawn(move || {
|
||||
run_host_reader_thread(
|
||||
reader,
|
||||
shared_for_thread,
|
||||
incoming_tx,
|
||||
handler,
|
||||
rate_limiter,
|
||||
)
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
shared,
|
||||
incoming: Mutex::new(incoming_rx),
|
||||
next_id: AtomicU64::new(1),
|
||||
reader_handle: Mutex::new(Some(handle)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn is_alive(&self) -> bool {
|
||||
self.shared.alive.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// Send a host-to-plugin notification.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn notify_plugin(
|
||||
&self,
|
||||
command_id: Option<u64>,
|
||||
notification: HostNotification,
|
||||
) -> Result<(), PluginError> {
|
||||
let envelope = NotificationEnvelope {
|
||||
command_id,
|
||||
payload: notification,
|
||||
};
|
||||
let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let stream = writer.as_mut().ok_or_else(|| {
|
||||
PluginError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotConnected,
|
||||
"V4 connection is shut down",
|
||||
))
|
||||
})?;
|
||||
send(stream, &HostToPluginV4::Notification(envelope))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a V4 request and block on the matching response, handling any
|
||||
/// nested plugin requests inline using `host`.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn call(
|
||||
&self,
|
||||
host: &mut dyn HostApi,
|
||||
req: HostRequest,
|
||||
on_start_interactive: &mut dyn FnMut(u64),
|
||||
) -> Result<HostResponse, PluginError> {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let kind = request_kind(&req);
|
||||
let timeout = request_timeout(kind);
|
||||
let deadline = Instant::now() + timeout;
|
||||
|
||||
{
|
||||
let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let stream = writer.as_mut().ok_or_else(|| {
|
||||
PluginError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotConnected,
|
||||
"V4 connection is shut down",
|
||||
))
|
||||
})?;
|
||||
send(stream, &HostToPluginV4::Request { id, payload: req })?;
|
||||
}
|
||||
|
||||
let incoming = self.incoming.lock().unwrap_or_else(|e| e.into_inner());
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
if remaining.is_zero() {
|
||||
self.shared.alive.store(false, Ordering::SeqCst);
|
||||
return Err(PluginError::CallTimeout {
|
||||
request: kind,
|
||||
duration: timeout,
|
||||
});
|
||||
}
|
||||
match incoming.recv_timeout(remaining) {
|
||||
Ok(HostIncoming::Response { id: rid, payload }) if rid == id => return Ok(payload),
|
||||
Ok(HostIncoming::Response { payload, .. }) => {
|
||||
return Err(PluginError::UnexpectedResponse(Box::new(payload)))
|
||||
}
|
||||
Ok(HostIncoming::Request { id: rid, payload }) => {
|
||||
let resp = handle_plugin_request(host, *payload, on_start_interactive);
|
||||
let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let stream = writer.as_mut().ok_or_else(|| {
|
||||
PluginError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotConnected,
|
||||
"V4 connection is shut down",
|
||||
))
|
||||
})?;
|
||||
send(stream, &HostToPluginV4::Response { id: rid, payload: resp })?;
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Timeout) => {
|
||||
self.shared.alive.store(false, Ordering::SeqCst);
|
||||
return Err(PluginError::CallTimeout {
|
||||
request: kind,
|
||||
duration: timeout,
|
||||
});
|
||||
}
|
||||
Err(mpsc::RecvTimeoutError::Disconnected) => {
|
||||
self.shared.alive.store(false, Ordering::SeqCst);
|
||||
return Err(PluginError::Runner(
|
||||
"V4 reader thread disconnected".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drain any plugin→host requests that have arrived since the last call and
|
||||
/// apply them using `host`. This lets long-lived plugin sessions (e.g. a
|
||||
/// Python REPL) mutate the host document while the host is not otherwise in
|
||||
/// a plugin call.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn drain_requests(
|
||||
&self,
|
||||
host: &mut dyn HostApi,
|
||||
on_start_interactive: &mut dyn FnMut(u64),
|
||||
) -> Result<(), PluginError> {
|
||||
let incoming = self.incoming.lock().unwrap_or_else(|e| e.into_inner());
|
||||
loop {
|
||||
match incoming.try_recv() {
|
||||
Ok(HostIncoming::Request { id, payload }) => {
|
||||
let resp = handle_plugin_request(host, *payload, on_start_interactive);
|
||||
let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let stream = writer.as_mut().ok_or_else(|| {
|
||||
PluginError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotConnected,
|
||||
"V4 connection is shut down",
|
||||
))
|
||||
})?;
|
||||
send(stream, &HostToPluginV4::Response { id, payload: resp })?;
|
||||
}
|
||||
Ok(HostIncoming::Response { .. }) => {
|
||||
// Responses without a matching active call should not
|
||||
// happen; drop them to avoid blocking the queue.
|
||||
continue;
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => return Ok(()),
|
||||
Err(mpsc::TryRecvError::Disconnected) => {
|
||||
self.shared.alive.store(false, Ordering::SeqCst);
|
||||
return Err(PluginError::Runner(
|
||||
"V4 reader thread disconnected".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Send an `ExecuteCode` request and block until the plugin returns the
|
||||
/// result. The active document tab index is included so the REPL session is
|
||||
/// tied to the tab that issued it.
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn execute_code(
|
||||
&self,
|
||||
host: &mut dyn HostApi,
|
||||
command_id: u64,
|
||||
source: CommandSource,
|
||||
code: &str,
|
||||
) -> Result<ExecutionResult, PluginError> {
|
||||
let req = HostRequest::ExecuteCode {
|
||||
command_id,
|
||||
source,
|
||||
code: code.to_string(),
|
||||
tab_index: host.tab_index(),
|
||||
};
|
||||
match self.call(host, req, &mut |_| {})? {
|
||||
HostResponse::CodeExecutionResult(result) => Ok(result),
|
||||
other => Err(PluginError::UnexpectedResponse(Box::new(other))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Shut down the V4 connection. The writer half is dropped, which causes
|
||||
/// the reader thread to exit, then the thread handle is joined.
|
||||
pub fn shutdown(&self) {
|
||||
{
|
||||
let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Ask the runner to shut down gracefully so the plugin has a chance
|
||||
// to clean up spawned child processes (e.g. the Python REPL). The
|
||||
// runner will send an Ack, then observe EOF on the next loop
|
||||
// iteration and exit.
|
||||
if let Some(stream) = writer.as_mut() {
|
||||
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let _ = send(
|
||||
stream,
|
||||
&HostToPluginV4::Request {
|
||||
id,
|
||||
payload: HostRequest::Shutdown,
|
||||
},
|
||||
);
|
||||
}
|
||||
let _ = writer.take();
|
||||
}
|
||||
// Dropping the writer closes the socket. The reader thread will then
|
||||
// exit and clear `alive`. Joining can deadlock if a plugin child
|
||||
// inherited the socket handle, so we only poll briefly.
|
||||
let deadline = Instant::now() + Duration::from_secs(3);
|
||||
while self.shared.alive.load(Ordering::SeqCst) && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
let _ = self.reader_handle.lock().unwrap_or_else(|e| e.into_inner()).take();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "host"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::thread;
|
||||
|
||||
use crate::host::{CommandSource, DocumentReader, ExecutionResult, ReaderEntity};
|
||||
use crate::ipc::protocol::{HostResponse, PluginRequest, PluginResponse};
|
||||
use crate::ipc::transport::recv;
|
||||
use crate::ipc::v4::protocol::{HostToPluginV4, PluginToHostV4};
|
||||
use acadrust::{CadDocument, Handle};
|
||||
use interprocess::local_socket::{
|
||||
traits::{Listener, Stream as StreamTrait},
|
||||
GenericNamespaced, ListenerOptions, ToNsName,
|
||||
};
|
||||
|
||||
fn unique_socket_name() -> String {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("ocs_plugin_v4_proc_test_{}_{}", std::process::id(), n)
|
||||
}
|
||||
|
||||
fn connect_pair() -> (Stream, Stream) {
|
||||
let name = unique_socket_name();
|
||||
let name_ref = name
|
||||
.clone()
|
||||
.to_ns_name::<GenericNamespaced>()
|
||||
.expect("valid name");
|
||||
let listener = ListenerOptions::new()
|
||||
.name(name_ref)
|
||||
.create_sync()
|
||||
.expect("listener");
|
||||
let client_name = name.clone();
|
||||
let client_thread = thread::spawn(move || {
|
||||
StreamTrait::connect(client_name.to_ns_name::<GenericNamespaced>().unwrap())
|
||||
.expect("connect")
|
||||
});
|
||||
let server = listener.accept().expect("accept");
|
||||
let client = client_thread.join().expect("client thread");
|
||||
(server, client)
|
||||
}
|
||||
|
||||
struct EmptyReader;
|
||||
impl DocumentReader for EmptyReader {
|
||||
fn entity_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
fn for_each_entity(&self, _f: &mut dyn FnMut(ReaderEntity<'_>)) {}
|
||||
fn layer_name(&self, _handle: Handle) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
fn app_id_name(&self, _handle: Handle) -> Option<&str> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
struct DummyHost;
|
||||
impl HostApi for DummyHost {
|
||||
fn tab_index(&self) -> usize {
|
||||
0
|
||||
}
|
||||
fn document(&self) -> &CadDocument {
|
||||
panic!("not used")
|
||||
}
|
||||
fn document_mut(&mut self) -> &mut CadDocument {
|
||||
panic!("not used")
|
||||
}
|
||||
fn document_reader(&self) -> Box<dyn DocumentReader + '_> {
|
||||
Box::new(EmptyReader)
|
||||
}
|
||||
fn add_entity(&mut self, _entity: acadrust::EntityType) -> Handle {
|
||||
panic!("not used")
|
||||
}
|
||||
fn bump_geometry(&mut self) {}
|
||||
fn read_record(
|
||||
&self,
|
||||
_handle: Handle,
|
||||
_app_name: &str,
|
||||
) -> Option<&acadrust::xdata::ExtendedDataRecord> {
|
||||
None
|
||||
}
|
||||
fn write_record(
|
||||
&mut self,
|
||||
_handle: Handle,
|
||||
_record: acadrust::xdata::ExtendedDataRecord,
|
||||
) -> bool {
|
||||
false
|
||||
}
|
||||
fn remove_record(&mut self, _handle: 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")
|
||||
}
|
||||
}
|
||||
|
||||
use std::sync::Mutex as StdMutex;
|
||||
|
||||
static ENV_LOCK: StdMutex<()> = StdMutex::new(());
|
||||
|
||||
fn set_test_env() {
|
||||
std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", "2");
|
||||
std::env::set_var("OCS_PLUGIN_TEST_FLOOR_SECS", "0");
|
||||
}
|
||||
|
||||
fn restore_test_env() {
|
||||
std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS");
|
||||
std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_code_timeout_floor_is_at_least_60s_and_env_override() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS");
|
||||
assert!(
|
||||
request_timeout("ExecuteCode") >= Duration::from_secs(60),
|
||||
"ExecuteCode floor should be at least 60 s"
|
||||
);
|
||||
|
||||
std::env::set_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS", "120");
|
||||
assert_eq!(
|
||||
request_timeout("ExecuteCode"),
|
||||
Duration::from_secs(120),
|
||||
"env override should be respected"
|
||||
);
|
||||
std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_connection_request_response() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
set_test_env();
|
||||
let (host_stream, mut runner_stream) = connect_pair();
|
||||
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||
Arc::new(|_, _| {});
|
||||
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||
|
||||
let runner = thread::spawn(move || {
|
||||
// Read the host's V4 request and respond.
|
||||
let req = recv::<HostToPluginV4>(&mut runner_stream).unwrap();
|
||||
match req {
|
||||
HostToPluginV4::Request { id, payload: HostRequest::Dispatch { cmd } } => {
|
||||
assert_eq!(cmd, "HELLO");
|
||||
send(
|
||||
&mut runner_stream,
|
||||
&PluginToHostV4::Response {
|
||||
id,
|
||||
payload: HostResponse::Bool(true),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
});
|
||||
|
||||
let mut host = DummyHost;
|
||||
let resp = conn
|
||||
.call(&mut host, HostRequest::Dispatch { cmd: "HELLO".to_string() }, &mut |_| {})
|
||||
.unwrap();
|
||||
assert!(matches!(resp, HostResponse::Bool(true)));
|
||||
runner.join().unwrap();
|
||||
restore_test_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_connection_handles_nested_plugin_request() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
set_test_env();
|
||||
let (host_stream, mut runner_stream) = connect_pair();
|
||||
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||
Arc::new(|_, _| {});
|
||||
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||
|
||||
let runner = thread::spawn(move || {
|
||||
let req = recv::<HostToPluginV4>(&mut runner_stream).unwrap();
|
||||
match req {
|
||||
HostToPluginV4::Request { id, payload: HostRequest::Dispatch { .. } } => {
|
||||
// Send a nested plugin request.
|
||||
send(
|
||||
&mut runner_stream,
|
||||
&PluginToHostV4::Request {
|
||||
id: 99,
|
||||
payload: PluginRequest::PushInfo("nested".to_string()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// Read the response to the nested request.
|
||||
let resp = recv::<HostToPluginV4>(&mut runner_stream).unwrap();
|
||||
match resp {
|
||||
HostToPluginV4::Response { id: rid, payload: PluginResponse::Ok }
|
||||
if rid == 99 => {}
|
||||
other => panic!("unexpected nested response: {other:?}"),
|
||||
}
|
||||
// Now respond to the original dispatch.
|
||||
send(
|
||||
&mut runner_stream,
|
||||
&PluginToHostV4::Response {
|
||||
id,
|
||||
payload: HostResponse::Bool(true),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
});
|
||||
|
||||
let mut host = DummyHost;
|
||||
let resp = conn
|
||||
.call(&mut host, HostRequest::Dispatch { cmd: "NESTED".to_string() }, &mut |_| {})
|
||||
.unwrap();
|
||||
assert!(matches!(resp, HostResponse::Bool(true)));
|
||||
runner.join().unwrap();
|
||||
restore_test_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_connection_notification_invokes_handler() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
set_test_env();
|
||||
use crate::host::LogLevel;
|
||||
|
||||
let (host_stream, mut runner_stream) = connect_pair();
|
||||
let received = Arc::new(Mutex::new(Vec::new()));
|
||||
let received2 = Arc::clone(&received);
|
||||
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||
Arc::new(move |cmd_id, notif| {
|
||||
received2.lock().unwrap().push((cmd_id, notif));
|
||||
});
|
||||
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||
|
||||
let runner = thread::spawn(move || {
|
||||
send(
|
||||
&mut runner_stream,
|
||||
&PluginToHostV4::Notification(NotificationEnvelope {
|
||||
command_id: Some(42),
|
||||
payload: PluginNotification::Log {
|
||||
level: LogLevel::Info,
|
||||
text: "hello".to_string(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
});
|
||||
|
||||
// Wait a bit for the notification to be delivered.
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
let got = received.lock().unwrap().clone();
|
||||
assert_eq!(got.len(), 1);
|
||||
assert_eq!(got[0].0, Some(42));
|
||||
runner.join().unwrap();
|
||||
drop(conn);
|
||||
restore_test_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_connection_handler_panic_is_isolated() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
set_test_env();
|
||||
use crate::host::LogLevel;
|
||||
|
||||
let (host_stream, mut runner_stream) = connect_pair();
|
||||
let received = Arc::new(Mutex::new(Vec::new()));
|
||||
let received2 = Arc::clone(&received);
|
||||
let panic_next = AtomicBool::new(true);
|
||||
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||
Arc::new(move |cmd_id, notif| {
|
||||
if panic_next.swap(false, Ordering::SeqCst) {
|
||||
panic!("deliberate handler panic");
|
||||
}
|
||||
received2.lock().unwrap().push((cmd_id, notif));
|
||||
});
|
||||
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||
|
||||
let runner = thread::spawn(move || {
|
||||
for i in 0..2 {
|
||||
send(
|
||||
&mut runner_stream,
|
||||
&PluginToHostV4::Notification(NotificationEnvelope {
|
||||
command_id: Some(i),
|
||||
payload: PluginNotification::Log {
|
||||
level: LogLevel::Info,
|
||||
text: "hi".to_string(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
});
|
||||
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let got = received.lock().unwrap().clone();
|
||||
assert_eq!(got.len(), 1, "second notification should survive handler panic");
|
||||
assert_eq!(got[0].0, Some(1));
|
||||
runner.join().unwrap();
|
||||
drop(conn);
|
||||
restore_test_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_connection_notification_rate_limiter_drops_excess() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
set_test_env();
|
||||
std::env::set_var("OCS_PLUGIN_NOTIFY_RATE_LIMIT", "2");
|
||||
use crate::host::LogLevel;
|
||||
|
||||
let (host_stream, mut runner_stream) = connect_pair();
|
||||
let received = Arc::new(Mutex::new(0u32));
|
||||
let received2 = Arc::clone(&received);
|
||||
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||
Arc::new(move |_, _| {
|
||||
*received2.lock().unwrap() += 1;
|
||||
});
|
||||
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||
|
||||
let runner = thread::spawn(move || {
|
||||
for _ in 0..10 {
|
||||
send(
|
||||
&mut runner_stream,
|
||||
&PluginToHostV4::Notification(NotificationEnvelope {
|
||||
command_id: None,
|
||||
payload: PluginNotification::Log {
|
||||
level: LogLevel::Info,
|
||||
text: "flood".to_string(),
|
||||
},
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// Keep the stream open long enough for the host to verify the
|
||||
// reader thread is still alive after the flood.
|
||||
std::thread::sleep(Duration::from_millis(300));
|
||||
});
|
||||
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
let count = *received.lock().unwrap();
|
||||
assert!(
|
||||
count <= 3,
|
||||
"rate limiter should drop most notifications, got {count}"
|
||||
);
|
||||
assert!(conn.is_alive(), "connection should survive rate-limited flood");
|
||||
runner.join().unwrap();
|
||||
drop(conn);
|
||||
std::env::remove_var("OCS_PLUGIN_NOTIFY_RATE_LIMIT");
|
||||
restore_test_env();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execute_code_returns_delayed_result_with_tab_index() {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
set_test_env();
|
||||
|
||||
let (host_stream, mut runner_stream) = connect_pair();
|
||||
let handler: Arc<dyn Fn(Option<u64>, PluginNotification) + Send + Sync> =
|
||||
Arc::new(|_, _| {});
|
||||
let conn = V4Connection::new(host_stream, handler).unwrap();
|
||||
|
||||
let runner = thread::spawn(move || {
|
||||
let req = recv::<HostToPluginV4>(&mut runner_stream).unwrap();
|
||||
match req {
|
||||
HostToPluginV4::Request {
|
||||
id,
|
||||
payload:
|
||||
HostRequest::ExecuteCode {
|
||||
command_id,
|
||||
source,
|
||||
code,
|
||||
tab_index,
|
||||
},
|
||||
} => {
|
||||
assert_eq!(command_id, 1);
|
||||
assert_eq!(source, CommandSource::Editor);
|
||||
assert_eq!(code, "1+1");
|
||||
assert_eq!(tab_index, 0, "ExecuteCode should be tied to the host's tab index");
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
send(
|
||||
&mut runner_stream,
|
||||
&PluginToHostV4::Response {
|
||||
id,
|
||||
payload: HostResponse::CodeExecutionResult(ExecutionResult {
|
||||
success: true,
|
||||
output: Some("42".to_string()),
|
||||
error: None,
|
||||
error_type: None,
|
||||
traceback: None,
|
||||
line_number: None,
|
||||
column_number: None,
|
||||
duration_ms: 100.0,
|
||||
}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
other => panic!("unexpected: {other:?}"),
|
||||
}
|
||||
});
|
||||
|
||||
let mut host = DummyHost;
|
||||
let result = conn
|
||||
.execute_code(&mut host, 1, CommandSource::Editor, "1+1")
|
||||
.expect("execute_code should succeed");
|
||||
assert!(result.success);
|
||||
assert_eq!(result.output, Some("42".to_string()));
|
||||
runner.join().unwrap();
|
||||
restore_test_env();
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ use crate::ipc::protocol::{
|
||||
HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, PLUGIN_TOKEN_ENV,
|
||||
};
|
||||
use crate::ipc::transport::{recv, send};
|
||||
use crate::ipc::v4::client::{RunnerFrame, V4Client};
|
||||
use crate::ribbon::owned::OwnedRibbonGroup;
|
||||
|
||||
/// Entry point for the plugin runner child process.
|
||||
@@ -26,7 +27,11 @@ use crate::ribbon::owned::OwnedRibbonGroup;
|
||||
/// 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 the plugin locate its own cdylib so it can copy it into generated
|
||||
// workspaces (e.g., as a Python extension). This is set only in the child
|
||||
// runner process, so it does not leak into the host environment.
|
||||
std::env::set_var("OCS_PLUGIN_CDYLIB_PATH", cdylib_path.as_os_str());
|
||||
let (version, mut plugin) = unsafe { load_plugin(cdylib_path)? };
|
||||
let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new()));
|
||||
|
||||
let token = match std::env::var(PLUGIN_TOKEN_ENV) {
|
||||
@@ -37,29 +42,68 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box<dyn std::err
|
||||
}
|
||||
};
|
||||
|
||||
let client = IpcClient::connect(socket_name)?;
|
||||
eprintln!("[runner] connected to host");
|
||||
client.send_handshake(&token)?;
|
||||
if version >= 4 {
|
||||
let client = V4Client::connect_handshake(socket_name, &token)?;
|
||||
run_v4(&mut *plugin, &interactive, client)
|
||||
} else {
|
||||
let client = IpcClient::connect(socket_name)?;
|
||||
eprintln!("[runner] connected to host");
|
||||
client.send_handshake(&token)?;
|
||||
run_v3(&*plugin, &interactive, client)
|
||||
}
|
||||
}
|
||||
|
||||
fn run_v3(
|
||||
plugin: &dyn BuiltinPlugin,
|
||||
interactive: &InteractiveRegistry,
|
||||
client: IpcClient,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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);
|
||||
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 run_v4(
|
||||
plugin: &mut dyn BuiltinPlugin,
|
||||
interactive: &InteractiveRegistry,
|
||||
client: V4Client,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
loop {
|
||||
// Drain any host notifications and dispatch them to the plugin's
|
||||
// state-only callback. Panics are caught so a bad callback does not
|
||||
// tear down the connection.
|
||||
while let Some((command_id, notification)) = client.try_recv_notification() {
|
||||
let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
plugin.on_notification(command_id, notification)
|
||||
}));
|
||||
}
|
||||
|
||||
match client.recv_runner_frame() {
|
||||
Ok(RunnerFrame::Request { id, payload }) => {
|
||||
if let Some(resp) = handle_host_request_v4(&mut *plugin, interactive, &client, id, payload) {
|
||||
client.send_response(id, resp)?;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Host disconnected.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_host_request(
|
||||
plugin: &dyn BuiltinPlugin,
|
||||
interactive: &InteractiveRegistry,
|
||||
@@ -115,7 +159,7 @@ fn handle_host_request(
|
||||
}))
|
||||
};
|
||||
match step {
|
||||
Ok(s) => HostResponse::CommandStep(s),
|
||||
Ok(s) => HostResponse::CommandStep(Box::new(s)),
|
||||
Err(_) => HostResponse::Error("interactive command panicked".to_string()),
|
||||
}
|
||||
}
|
||||
@@ -147,14 +191,143 @@ fn handle_host_request(
|
||||
None => HostResponse::Error(format!("unknown interactive command {command_id}")),
|
||||
}
|
||||
}
|
||||
HostRequest::ExecuteCode { .. } => {
|
||||
HostResponse::Error("ExecuteCode requires V4 protocol".to_string())
|
||||
}
|
||||
HostRequest::Shutdown => {
|
||||
// The runner will exit after this response is sent.
|
||||
std::process::exit(0);
|
||||
// Return a positive Ack so the host knows we received the request.
|
||||
// run_v3 will then observe the closed stream, break its loop, and
|
||||
// return, allowing the plugin object to be dropped and clean up
|
||||
// spawned child processes before the runner process exits.
|
||||
HostResponse::Bool(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn load_plugin(path: &Path) -> Result<Box<dyn BuiltinPlugin>, Box<dyn std::error::Error>> {
|
||||
fn handle_host_request_v4(
|
||||
plugin: &mut dyn BuiltinPlugin,
|
||||
interactive: &InteractiveRegistry,
|
||||
client: &V4Client,
|
||||
id: u64,
|
||||
req: HostRequest,
|
||||
) -> Option<HostResponse> {
|
||||
match req {
|
||||
HostRequest::GetManifest => {
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.manifest())) {
|
||||
Ok(m) => Some(HostResponse::Manifest(m.into())),
|
||||
Err(_) => Some(HostResponse::Error("plugin manifest() panicked".to_string())),
|
||||
}
|
||||
}
|
||||
HostRequest::GetRibbon => {
|
||||
match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.ribbon())) {
|
||||
Ok(groups) => Some(HostResponse::Ribbon(
|
||||
groups
|
||||
.ribbon_groups()
|
||||
.iter()
|
||||
.map(OwnedRibbonGroup::from)
|
||||
.collect(),
|
||||
)),
|
||||
Err(_) => Some(HostResponse::Error("plugin ribbon() panicked".to_string())),
|
||||
}
|
||||
}
|
||||
HostRequest::Dispatch { cmd } => {
|
||||
let mut proxy = client.plugin_host_api(0, interactive.clone());
|
||||
let handled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
plugin.dispatch(&mut proxy, &cmd)
|
||||
}));
|
||||
match handled {
|
||||
Ok(b) => Some(HostResponse::Bool(b)),
|
||||
Err(_) => Some(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 Some(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) => Some(HostResponse::CommandStep(Box::new(s))),
|
||||
Err(_) => Some(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)) => Some(HostResponse::Text(s)),
|
||||
Some(Err(_)) => Some(HostResponse::Error("prompt() panicked".to_string())),
|
||||
None => Some(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)) => Some(HostResponse::Bool(b)),
|
||||
Some(Err(_)) => Some(HostResponse::Error("needs_object_pick() panicked".to_string())),
|
||||
None => Some(HostResponse::Error(format!("unknown interactive command {command_id}"))),
|
||||
}
|
||||
}
|
||||
HostRequest::ExecuteCode {
|
||||
command_id,
|
||||
source,
|
||||
code,
|
||||
tab_index,
|
||||
} => {
|
||||
let mut proxy = client.plugin_host_api(tab_index, interactive.clone());
|
||||
let respond = client.execute_code_responder(id);
|
||||
let started = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
plugin.start_execute_code(
|
||||
&mut proxy,
|
||||
command_id,
|
||||
&code,
|
||||
source,
|
||||
respond,
|
||||
)
|
||||
}));
|
||||
match started {
|
||||
Ok(true) => None,
|
||||
Ok(false) => Some(HostResponse::Error(
|
||||
"plugin does not support code execution".to_string(),
|
||||
)),
|
||||
Err(_) => Some(HostResponse::Error(
|
||||
"plugin start_execute_code panicked".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
HostRequest::Shutdown => {
|
||||
// Return a positive Ack so the host knows we received the request.
|
||||
// run_v4 will then observe the closed V4 writer, break its loop,
|
||||
// and return, allowing the plugin object to be dropped and clean up
|
||||
// spawned child processes before the runner process exits.
|
||||
Some(HostResponse::Bool(true))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn load_plugin(path: &Path) -> Result<(u32, Box<dyn BuiltinPlugin>), Box<dyn std::error::Error>> {
|
||||
let lib = libloading::Library::new(path)?;
|
||||
|
||||
let version: libloading::Symbol<extern "C" fn() -> u32> = lib
|
||||
@@ -165,7 +338,7 @@ unsafe fn load_plugin(path: &Path) -> Result<Box<dyn BuiltinPlugin>, Box<dyn std
|
||||
return Err(format!(
|
||||
"API version {v} is incompatible (host supports {}-{})",
|
||||
crate::API_VERSION_MIN_SUPPORTED,
|
||||
crate::API_VERSION
|
||||
crate::effective_max_api_version()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
@@ -183,5 +356,111 @@ unsafe fn load_plugin(path: &Path) -> Result<Box<dyn BuiltinPlugin>, Box<dyn std
|
||||
// lifetime of the process. The runner exits when the host disconnects.
|
||||
let _ = std::mem::ManuallyDrop::new(lib);
|
||||
|
||||
Ok(plugin)
|
||||
Ok((v, plugin))
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "host"))]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
use std::rc::Rc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::thread;
|
||||
|
||||
use interprocess::local_socket::{
|
||||
traits::{Listener, Stream as StreamTrait},
|
||||
GenericNamespaced, ListenerOptions, Stream, ToNsName,
|
||||
};
|
||||
|
||||
use super::*;
|
||||
use crate::host::{BuiltinPlugin, CommandSource, HostApi};
|
||||
use crate::ipc::protocol::HostRequest;
|
||||
use crate::ipc::v4::client::V4Client;
|
||||
use crate::manifest::{ApiVersion, PluginManifest};
|
||||
use crate::ribbon::{CadModule, RibbonGroup};
|
||||
|
||||
static TEST_MANIFEST: PluginManifest = PluginManifest {
|
||||
id: "test.repl",
|
||||
name: "Test REPL",
|
||||
version: "0.1.0",
|
||||
description: "test",
|
||||
api_version: ApiVersion { major: 4 },
|
||||
ribbon_order: 0,
|
||||
xdata_apps: &[],
|
||||
command_prefixes: &[],
|
||||
};
|
||||
|
||||
struct EmptyModule;
|
||||
impl CadModule for EmptyModule {
|
||||
fn id(&self) -> &'static str {
|
||||
"test.repl"
|
||||
}
|
||||
fn title(&self) -> &'static str {
|
||||
"Test"
|
||||
}
|
||||
fn ribbon_groups(&self) -> &[RibbonGroup] {
|
||||
&[]
|
||||
}
|
||||
}
|
||||
|
||||
struct UnsupportedReplPlugin;
|
||||
impl BuiltinPlugin for UnsupportedReplPlugin {
|
||||
fn manifest(&self) -> &'static PluginManifest {
|
||||
&TEST_MANIFEST
|
||||
}
|
||||
fn ribbon(&self) -> Box<dyn CadModule> {
|
||||
Box::new(EmptyModule)
|
||||
}
|
||||
fn dispatch(&self, _host: &mut dyn HostApi, _cmd: &str) -> bool {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn unique_socket_name() -> String {
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
format!("ocs_plugin_runner_test_{}_{}", std::process::id(), n)
|
||||
}
|
||||
|
||||
fn connect_pair() -> (Stream, Stream) {
|
||||
let name = unique_socket_name();
|
||||
let name_ref = name
|
||||
.clone()
|
||||
.to_ns_name::<GenericNamespaced>()
|
||||
.expect("valid name");
|
||||
let listener = ListenerOptions::new()
|
||||
.name(name_ref)
|
||||
.create_sync()
|
||||
.expect("listener");
|
||||
let client_name = name.clone();
|
||||
let client_thread = thread::spawn(move || {
|
||||
StreamTrait::connect(client_name.to_ns_name::<GenericNamespaced>().unwrap())
|
||||
.expect("connect")
|
||||
});
|
||||
let server = listener.accept().expect("accept");
|
||||
let client = client_thread.join().expect("client thread");
|
||||
(server, client)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_runner_execute_code_unsupported_returns_error() {
|
||||
let (host_stream, runner_stream) = connect_pair();
|
||||
let client = V4Client::from_stream_for_test(runner_stream);
|
||||
let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new()));
|
||||
let mut plugin = UnsupportedReplPlugin;
|
||||
let req = HostRequest::ExecuteCode {
|
||||
command_id: 1,
|
||||
source: CommandSource::Editor,
|
||||
code: "1+1".to_string(),
|
||||
tab_index: 0,
|
||||
};
|
||||
let resp = handle_host_request_v4(&mut plugin, &interactive, &client, 99, req);
|
||||
match resp {
|
||||
Some(HostResponse::Error(msg)) => {
|
||||
assert!(msg.contains("does not support code execution"));
|
||||
}
|
||||
other => panic!("expected unsupported error, got {other:?}"),
|
||||
}
|
||||
drop(host_stream);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@
|
||||
//! rkyv-serialized view of the active document. The plugin maps the same file
|
||||
//! read-only and reads entity/layer data directly from the mapping without
|
||||
//! copying the full `CadDocument` into its own address space.
|
||||
//!
|
||||
//! This module is generic over the payload type so that the V3 simplified view
|
||||
//! and the V4 bincode-per-entity view can share the same double-buffered
|
||||
//! control-page logic.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io;
|
||||
@@ -33,29 +37,51 @@ pub struct DocumentViewInfo {
|
||||
pub version: u64,
|
||||
}
|
||||
|
||||
/// Marker trait for types that can be placed in a shared-memory snapshot.
|
||||
///
|
||||
/// Implementers provide rkyv serialization/deserialization without exposing
|
||||
/// rkyv's generic serializer types in the public API.
|
||||
pub trait SnapshotData: Clone + std::fmt::Debug + Send + Sync + 'static {
|
||||
/// The archived (zero-copy) type returned by [`Self::check_bytes`].
|
||||
type Archived: 'static;
|
||||
|
||||
/// Serialize `self` into rkyv bytes.
|
||||
fn to_rkyv_bytes(&self) -> io::Result<Vec<u8>>;
|
||||
|
||||
/// Validate `bytes` and return a reference to the archived type.
|
||||
fn check_rkyv_bytes(bytes: &[u8]) -> Option<&Self::Archived>;
|
||||
}
|
||||
|
||||
/// Host-side, file-backed double buffer for the document view.
|
||||
pub struct DocumentSnapshotStore {
|
||||
pub struct DocumentSnapshotStore<T: SnapshotData> {
|
||||
path: PathBuf,
|
||||
mmap: MmapMut,
|
||||
segment_size: usize,
|
||||
current_version: u64,
|
||||
_phantom: std::marker::PhantomData<T>,
|
||||
}
|
||||
|
||||
impl DocumentSnapshotStore {
|
||||
/// Create a new store for `tab`. `segment_size` is the maximum size of one
|
||||
impl<T: SnapshotData> DocumentSnapshotStore<T> {
|
||||
/// Create a new store for `tab_id`. `segment_size` is the maximum size of one
|
||||
/// snapshot buffer; the file is sized to hold two segments plus the control
|
||||
/// page.
|
||||
pub fn new(tab: usize, segment_size: usize) -> io::Result<Self> {
|
||||
pub fn new(tab_id: u64, segment_size: usize) -> io::Result<Self> {
|
||||
let segment_size = segment_size.next_multiple_of(4096);
|
||||
static STORE_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
let id = STORE_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let path = Self::temp_path(tab, id);
|
||||
let path = Self::temp_path(tab_id, id);
|
||||
let file = OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.open(&path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::fs::Permissions;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
file.set_permissions(Permissions::from_mode(0o600))?;
|
||||
}
|
||||
let total = CONTROL_SIZE + 2 * segment_size;
|
||||
file.set_len(total as u64)?;
|
||||
|
||||
@@ -72,15 +98,16 @@ impl DocumentSnapshotStore {
|
||||
mmap,
|
||||
segment_size,
|
||||
current_version: 0,
|
||||
_phantom: std::marker::PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
fn temp_path(tab: usize, id: usize) -> PathBuf {
|
||||
fn temp_path(tab_id: u64, id: usize) -> PathBuf {
|
||||
let mut path = std::env::temp_dir();
|
||||
path.push(format!(
|
||||
"ocs_plugin_doc_{}_{}_{}_{}.bin",
|
||||
"ocs_plugin_doc_v4_{}_{}_{}_{}.bin",
|
||||
std::process::id(),
|
||||
tab,
|
||||
tab_id,
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
@@ -95,12 +122,9 @@ impl DocumentSnapshotStore {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Serialize `doc` into the inactive segment and atomically publish it.
|
||||
pub fn publish(&mut self, doc: &CadDocument) -> io::Result<()> {
|
||||
let data = DocumentViewData::from(doc);
|
||||
let bytes = to_bytes::<_, 256>(&data).map_err(|e| {
|
||||
io::Error::new(io::ErrorKind::InvalidData, format!("rkyv serialize: {e}"))
|
||||
})?;
|
||||
/// Serialize `data` into the inactive segment and atomically publish it.
|
||||
pub fn publish(&mut self, data: &T) -> io::Result<()> {
|
||||
let bytes = data.to_rkyv_bytes()?;
|
||||
if bytes.len() > self.segment_size {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::OutOfMemory,
|
||||
@@ -143,20 +167,21 @@ impl DocumentSnapshotStore {
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DocumentSnapshotStore {
|
||||
impl<T: SnapshotData> Drop for DocumentSnapshotStore<T> {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
/// Plugin-side read-only mapping of the host's document view.
|
||||
pub struct SharedDocumentReader {
|
||||
pub struct SharedDocumentReader<T: SnapshotData> {
|
||||
mmap: Mmap,
|
||||
segment_size: usize,
|
||||
cached_version: u64,
|
||||
_phantom: std::marker::PhantomData<T>,
|
||||
}
|
||||
|
||||
impl SharedDocumentReader {
|
||||
impl<T: SnapshotData> SharedDocumentReader<T> {
|
||||
/// Open the file at `path` read-only and map it. The mapping may initially
|
||||
/// contain no valid snapshot; the caller should `refresh()` before use.
|
||||
pub fn open(path: &Path) -> io::Result<Self> {
|
||||
@@ -172,6 +197,7 @@ impl SharedDocumentReader {
|
||||
mmap,
|
||||
segment_size,
|
||||
cached_version: 0,
|
||||
_phantom: std::marker::PhantomData,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -201,13 +227,28 @@ impl SharedDocumentReader {
|
||||
&self.mmap[offset..offset + len]
|
||||
}
|
||||
|
||||
fn archived(&self) -> Option<&ArchivedDocumentViewData> {
|
||||
fn archived(&self) -> Option<&T::Archived> {
|
||||
let bytes = self.active_segment_bytes();
|
||||
check_archived_root::<DocumentViewData>(bytes).ok()
|
||||
T::check_rkyv_bytes(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl DocumentReader for SharedDocumentReader {
|
||||
impl<T: SnapshotData> SharedDocumentReader<T> {
|
||||
/// Public accessor for the archived payload. Used by V4 consumers that
|
||||
/// need typed access to `DocumentViewDataV4` entities.
|
||||
pub fn payload(&self) -> Option<&T::Archived> {
|
||||
self.archived()
|
||||
}
|
||||
}
|
||||
|
||||
impl SharedDocumentReader<DocumentViewData> {
|
||||
/// V3 convenience accessor for the entity count.
|
||||
pub fn entity_count(&self) -> usize {
|
||||
self.archived().map(|doc| doc.entities.len()).unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
impl DocumentReader for SharedDocumentReader<DocumentViewData> {
|
||||
fn entity_count(&self) -> usize {
|
||||
self.archived().map(|doc| doc.entities.len()).unwrap_or(0)
|
||||
}
|
||||
@@ -251,6 +292,12 @@ impl DocumentReader for SharedDocumentReader {
|
||||
}
|
||||
}
|
||||
|
||||
/// V3 alias: host-side snapshot store for the simplified document view.
|
||||
pub type DocumentSnapshotStoreV3 = DocumentSnapshotStore<DocumentViewData>;
|
||||
|
||||
/// V3 alias: plugin-side reader for the simplified document view.
|
||||
pub type SharedDocumentReaderV3 = SharedDocumentReader<DocumentViewData>;
|
||||
|
||||
/// Raw control page shared between host and plugin.
|
||||
#[repr(C, align(8))]
|
||||
struct ControlPage {
|
||||
@@ -276,6 +323,8 @@ impl ControlPage {
|
||||
}
|
||||
}
|
||||
|
||||
// ── V3 simplified document view ─────────────────────────────────────────────
|
||||
|
||||
/// Serializable document view. This is the only data type placed in shared
|
||||
/// memory, so it must contain no pointers into host memory.
|
||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||
@@ -296,6 +345,20 @@ impl From<&CadDocument> for DocumentViewData {
|
||||
}
|
||||
}
|
||||
|
||||
impl SnapshotData for DocumentViewData {
|
||||
type Archived = ArchivedDocumentViewData;
|
||||
|
||||
fn to_rkyv_bytes(&self) -> io::Result<Vec<u8>> {
|
||||
to_bytes::<_, 256>(self)
|
||||
.map(|av| av.into_vec())
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
|
||||
}
|
||||
|
||||
fn check_rkyv_bytes(bytes: &[u8]) -> Option<&Self::Archived> {
|
||||
check_archived_root::<Self>(bytes).ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||
#[archive(check_bytes)]
|
||||
pub struct LayerView {
|
||||
@@ -395,6 +458,59 @@ impl ReaderEntityKind {
|
||||
}
|
||||
}
|
||||
|
||||
// ── V4 full-entity document view ────────────────────────────────────────────
|
||||
|
||||
/// Serializable V4 document view. The outer structure is rkyv; each entity's
|
||||
/// `data` is a bincode-encoded `acadrust::EntityType` so that the plugin can
|
||||
/// reconstruct the full typed entity without relying on acadrust's own rkyv
|
||||
/// support.
|
||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||
#[archive(check_bytes)]
|
||||
pub struct DocumentViewDataV4 {
|
||||
pub layers: Vec<LayerView>,
|
||||
pub app_ids: Vec<AppIdView>,
|
||||
pub entities: Vec<EntityViewV4>,
|
||||
}
|
||||
|
||||
impl From<&CadDocument> for DocumentViewDataV4 {
|
||||
fn from(doc: &CadDocument) -> Self {
|
||||
Self {
|
||||
layers: doc.layers.iter().map(LayerView::from).collect(),
|
||||
app_ids: doc.app_ids.iter().map(AppIdView::from).collect(),
|
||||
entities: doc.entities().map(EntityViewV4::from).collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SnapshotData for DocumentViewDataV4 {
|
||||
type Archived = ArchivedDocumentViewDataV4;
|
||||
|
||||
fn to_rkyv_bytes(&self) -> io::Result<Vec<u8>> {
|
||||
to_bytes::<_, 256>(self)
|
||||
.map(|av| av.into_vec())
|
||||
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string()))
|
||||
}
|
||||
|
||||
fn check_rkyv_bytes(bytes: &[u8]) -> Option<&Self::Archived> {
|
||||
check_archived_root::<Self>(bytes).ok()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Archive, Serialize, Deserialize, Debug, Clone)]
|
||||
#[archive(check_bytes)]
|
||||
pub struct EntityViewV4 {
|
||||
pub handle: u64,
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl From<&EntityType> for EntityViewV4 {
|
||||
fn from(entity: &EntityType) -> Self {
|
||||
let handle = entity.common().handle.value();
|
||||
let data = bincode::serialize(entity).unwrap_or_default();
|
||||
Self { handle, data }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -415,10 +531,10 @@ mod tests {
|
||||
#[test]
|
||||
fn shared_document_reader_roundtrip() {
|
||||
let doc = sample_doc();
|
||||
let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap();
|
||||
store.publish(&doc).unwrap();
|
||||
let mut store = DocumentSnapshotStore::<DocumentViewData>::new(0, 1024 * 1024).unwrap();
|
||||
store.publish(&(&doc).into()).unwrap();
|
||||
|
||||
let reader = SharedDocumentReader::open(store.path()).unwrap();
|
||||
let reader = SharedDocumentReader::<DocumentViewData>::open(store.path()).unwrap();
|
||||
assert_eq!(reader.entity_count(), 1);
|
||||
|
||||
let mut seen = Vec::new();
|
||||
@@ -445,17 +561,17 @@ mod tests {
|
||||
#[test]
|
||||
fn shared_document_reader_updates_after_publish() {
|
||||
let doc = sample_doc();
|
||||
let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap();
|
||||
store.publish(&doc).unwrap();
|
||||
let mut store = DocumentSnapshotStore::<DocumentViewData>::new(0, 1024 * 1024).unwrap();
|
||||
store.publish(&(&doc).into()).unwrap();
|
||||
|
||||
let reader = SharedDocumentReader::open(store.path()).unwrap();
|
||||
let reader = SharedDocumentReader::<DocumentViewData>::open(store.path()).unwrap();
|
||||
assert_eq!(reader.entity_count(), 1);
|
||||
|
||||
let mut doc2 = doc;
|
||||
let mut point2 = Point::from_coords(1.0, 2.0, 3.0);
|
||||
point2.common.layer = "SURVEY".to_string();
|
||||
doc2.add_entity(EntityType::Point(point2)).unwrap();
|
||||
store.publish(&doc2).unwrap();
|
||||
store.publish(&(&doc2).into()).unwrap();
|
||||
|
||||
assert_eq!(reader.entity_count(), 2);
|
||||
}
|
||||
@@ -463,11 +579,28 @@ mod tests {
|
||||
#[test]
|
||||
fn layer_name_lookup_by_handle() {
|
||||
let doc = sample_doc();
|
||||
let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap();
|
||||
store.publish(&doc).unwrap();
|
||||
let mut store = DocumentSnapshotStore::<DocumentViewData>::new(0, 1024 * 1024).unwrap();
|
||||
store.publish(&(&doc).into()).unwrap();
|
||||
|
||||
let survey = doc.layers.iter().find(|l| l.name == "SURVEY").unwrap();
|
||||
let reader = SharedDocumentReader::open(store.path()).unwrap();
|
||||
let reader = SharedDocumentReader::<DocumentViewData>::open(store.path()).unwrap();
|
||||
assert_eq!(reader.layer_name(survey.handle), Some("SURVEY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v4_document_view_roundtrip() {
|
||||
let doc = sample_doc();
|
||||
let mut store = DocumentSnapshotStore::<DocumentViewDataV4>::new(7, 1024 * 1024).unwrap();
|
||||
let view: DocumentViewDataV4 = (&doc).into();
|
||||
store.publish(&view).unwrap();
|
||||
|
||||
let reader = SharedDocumentReader::<DocumentViewDataV4>::open(store.path()).unwrap();
|
||||
assert_eq!(reader.archived().map(|d| d.entities.len()).unwrap_or(0), 1);
|
||||
|
||||
let archived = reader.archived().unwrap();
|
||||
let entity = &archived.entities[0];
|
||||
assert_eq!(entity.handle, doc.entities().next().unwrap().common().handle.value());
|
||||
let decoded: EntityType = bincode::deserialize(&entity.data).expect("bincode decode");
|
||||
assert!(matches!(decoded, EntityType::Point(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Embedded type registry generated at build time.
|
||||
//!
|
||||
//! The JSON embedded here is produced by tracing a curated allow-list of
|
||||
//! `acadrust` types with `serde-reflection` and mapping the result into a
|
||||
//! stable, language-binding-friendly schema defined in
|
||||
//! [`crate::type_registry_types`].
|
||||
|
||||
pub use crate::type_registry_types::*;
|
||||
|
||||
/// The embedded type registry as a JSON string.
|
||||
pub const EMBEDDED_TYPE_REGISTRY_JSON: &str =
|
||||
include_str!(concat!(env!("OUT_DIR"), "/type_registry.json"));
|
||||
|
||||
/// Convenience accessor for the embedded type registry JSON.
|
||||
pub fn get_embedded_type_registry_json() -> &'static str {
|
||||
EMBEDDED_TYPE_REGISTRY_JSON
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn embedded_type_registry_is_valid_json() {
|
||||
let json = get_embedded_type_registry_json();
|
||||
assert!(!json.is_empty());
|
||||
let registry: TypeRegistry = serde_json::from_str(json).expect("valid TypeRegistry JSON");
|
||||
assert!(!registry.types.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_type_registry_contains_expected_acadrust_types() {
|
||||
let registry: TypeRegistry =
|
||||
serde_json::from_str(get_embedded_type_registry_json()).expect("valid TypeRegistry JSON");
|
||||
|
||||
for name in ["Point", "Line", "Circle", "EntityCommon"] {
|
||||
assert!(
|
||||
registry.types.contains_key(&TypeId::new(name)),
|
||||
"registry should contain {}",
|
||||
name
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_type_registry_point_is_struct_with_expected_fields() {
|
||||
let registry: TypeRegistry =
|
||||
serde_json::from_str(get_embedded_type_registry_json()).expect("valid TypeRegistry JSON");
|
||||
|
||||
let point = registry
|
||||
.types
|
||||
.get(&TypeId::new("Point"))
|
||||
.expect("Point in registry");
|
||||
assert_eq!(point.kind, TypeKind::Struct);
|
||||
|
||||
let field_names: Vec<&str> = point.fields.iter().map(|f| f.name.as_str()).collect();
|
||||
for expected in ["common", "location", "thickness", "normal"] {
|
||||
assert!(
|
||||
field_names.contains(&expected),
|
||||
"Point should have field {}",
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
let common = point
|
||||
.fields
|
||||
.iter()
|
||||
.find(|f| f.name == "common")
|
||||
.expect("common field");
|
||||
assert_eq!(common.type_id.as_str(), "EntityCommon");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_type_registry_does_not_contain_entity_type() {
|
||||
let registry: TypeRegistry =
|
||||
serde_json::from_str(get_embedded_type_registry_json()).expect("valid TypeRegistry JSON");
|
||||
assert!(
|
||||
!registry.types.contains_key(&TypeId::new("EntityType")),
|
||||
"EntityType should not be in the allow-list registry"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_id_as_str_returns_inner_value() {
|
||||
let id = TypeId::new("Point");
|
||||
assert_eq!(id.as_str(), "Point");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stable identifier for a type in the embedded registry.
|
||||
///
|
||||
/// `TypeId` serializes as a plain string so it can be used as a JSON object
|
||||
/// key. The inner value is intentionally private; consumers use `as_str()`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub struct TypeId(String);
|
||||
|
||||
impl TypeId {
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Classification of a type in the registry.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum TypeKind {
|
||||
Struct,
|
||||
Enum,
|
||||
Newtype,
|
||||
Tuple,
|
||||
Unit,
|
||||
}
|
||||
|
||||
/// Description of one field in a struct or enum variant.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FieldInfo {
|
||||
pub name: String,
|
||||
pub type_id: TypeId,
|
||||
pub optional: bool,
|
||||
pub is_sequence: bool,
|
||||
}
|
||||
|
||||
/// Description of one enum variant.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EnumVariantInfo {
|
||||
pub name: String,
|
||||
pub discriminant: u32,
|
||||
pub fields: Vec<FieldInfo>,
|
||||
}
|
||||
|
||||
/// Description of one method parameter.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ParameterInfo {
|
||||
pub name: String,
|
||||
pub type_id: TypeId,
|
||||
}
|
||||
|
||||
/// Description of one method on a type.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MethodInfo {
|
||||
pub name: String,
|
||||
pub parameters: Vec<ParameterInfo>,
|
||||
pub return_type: Option<TypeId>,
|
||||
}
|
||||
|
||||
/// Full description of one type in the registry.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TypeInfo {
|
||||
pub name: TypeId,
|
||||
pub kind: TypeKind,
|
||||
pub fields: Vec<FieldInfo>,
|
||||
pub variants: Vec<EnumVariantInfo>,
|
||||
pub methods: Vec<MethodInfo>,
|
||||
pub doc: Option<String>,
|
||||
}
|
||||
|
||||
/// Collection of all traced types, keyed by their `TypeId`.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TypeRegistry {
|
||||
pub types: std::collections::BTreeMap<TypeId, TypeInfo>,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
//! Embedded version metadata generated at build time.
|
||||
|
||||
/// The embedded version info as a JSON string.
|
||||
pub const EMBEDDED_VERSION_INFO_JSON: &str =
|
||||
include_str!(concat!(env!("OUT_DIR"), "/version_info.json"));
|
||||
|
||||
/// Convenience accessor for the embedded version info JSON.
|
||||
pub fn get_embedded_version_info_json() -> &'static str {
|
||||
EMBEDDED_VERSION_INFO_JSON
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn embedded_version_info_is_valid_json() {
|
||||
let json = get_embedded_version_info_json();
|
||||
assert!(!json.is_empty());
|
||||
let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
|
||||
assert!(value.get("ocs_version").is_some());
|
||||
assert!(value.get("acadrust_version").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_version_info_has_expected_keys() {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(get_embedded_version_info_json()).expect("valid JSON");
|
||||
for key in [
|
||||
"ocs_version",
|
||||
"ocs_plugin_api_version",
|
||||
"acadrust_version",
|
||||
"acadrust_source",
|
||||
"api_version",
|
||||
"api_version_min_supported",
|
||||
"build_timestamp",
|
||||
] {
|
||||
assert!(value.get(key).is_some(), "missing version info key {}", key);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_version_info_matches_package_versions() {
|
||||
let value: serde_json::Value =
|
||||
serde_json::from_str(get_embedded_version_info_json()).expect("valid JSON");
|
||||
assert_eq!(
|
||||
value["ocs_plugin_api_version"].as_str(),
|
||||
Some(env!("CARGO_PKG_VERSION"))
|
||||
);
|
||||
assert_eq!(value["api_version"].as_i64(), Some(4));
|
||||
assert_eq!(value["api_version_min_supported"].as_i64(), Some(2));
|
||||
|
||||
// acadrust_version should have a patch component (e.g. "0.4.0").
|
||||
let acadrust = value["acadrust_version"].as_str().expect("acadrust_version string");
|
||||
assert_eq!(acadrust.split('.').count(), 3);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user