Files
OneCAD-Draft/crates/ocs_plugin_api/src/ipc/v4/client.rs
T
Hakan SevenandClaude Opus 5.5 f537aff75c feat: node graph overlay; move to opencadcodec/opencadkernel/opencadgraph
The codec and kernel repositories and crates are renamed opencadcodec and
opencadkernel. Inside OCS they are depended on under short keys, so code
reads `codec::`, `kernel::` and the new `graph::` (opencadgraph, the node
graph engine). File-format and protocol spellings stay as they were: the
ACIS product id `acadrust`, the ACADRUST_* switches and the plugin
version keys `acadrust_version` / `acadrust_source`.

A button left of MCP on the command line opens a translucent node graph
over the viewport, with a palette of object, input, math, logic, list,
text and geometry nodes added by click or drag. An object node owns
drawing entities and its rows are the Properties panel's own rows for
them: the single-object page is extracted from refresh_properties as
entity_property_sections, values use the control protocol's
property_json, and writes go through set_property_value with a property
target override, so the panel, MCP and the graph share one read/write
path. A list reaching an object row makes one entity per element. Each
graph action is one undo step; the canvas pans and zooms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 07:34:23 +03:00

1169 lines
40 KiB
Rust

//! 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 codec::xdata::ExtendedDataRecord;
use codec::{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, HostSettingValue,
};
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<HashMap<u64, mpsc::Sender<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(HashMap::new()),
});
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()
}
/// Wait up to `timeout` for a host request so the runner can also drain
/// notifications while otherwise idle.
pub fn recv_runner_frame_timeout(
&self,
timeout: Duration,
) -> Result<RunnerFrame, mpsc::RecvTimeoutError> {
self.runner_queue.recv_timeout(timeout)
}
/// 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 }) => {
let tx = shared
.in_flight
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&id);
if let Some(tx) = tx {
let _ = tx.send(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;
}
}
}
}));
shared
.in_flight
.lock()
.unwrap_or_else(|e| e.into_inner())
.clear();
}
/// `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<(u64, 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, self.tab_id_cache.get(), 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>,
tab_id: Option<u64>,
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())
.insert(id, tx);
let send_result = {
let mut writer = shared.writer.lock().unwrap_or_else(|e| e.into_inner());
send(
&mut writer,
&PluginToHostV4::Request {
id,
tab_id,
payload: req,
},
)
};
if let Err(error) = send_result {
shared
.in_flight
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(&id);
return Err(error);
}
match rx.recv() {
Ok(resp) => Ok(resp),
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 for the originating document tab.
struct V4PluginRequestSender {
shared: Arc<Shared>,
tab_id: u64,
}
impl PluginRequestSender for V4PluginRequestSender {
fn request(&self, req: PluginRequest) -> Result<PluginResponse, PluginRequestError> {
send_plugin_request(&self.shared, Some(self.tab_id), 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),
tab_id: self.tab_id(),
}))
}
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.as_ref().map(|(cached_id, _)| *cached_id) != Some(tab_id) {
match self.request(PluginRequest::OpenDocumentViewV4 { tab_id }) {
Ok(PluginResponse::DocumentViewV4 { path, version }) => {
*view = Some((tab_id, DocumentViewInfo { path, version }));
}
Ok(other) => {
eprintln!("[plugin] unexpected OpenDocumentViewV4 response: {other:?}");
}
Err(e) => {
eprintln!("[plugin] OpenDocumentViewV4 request failed: {e}");
}
}
}
}
self.doc_view_v4
.borrow()
.as_ref()
.map(|(_, info)| info.clone())
}
fn close_document_view_v4(&mut self, tab_id: u64) {
let _ = self.request(PluginRequest::CloseDocumentViewV4 { tab_id });
let mut view = self.doc_view_v4.borrow_mut();
if view.as_ref().map(|(cached_id, _)| *cached_id) == Some(tab_id) {
view.take();
}
}
fn document_path(&self, tab_id: u64) -> Option<std::path::PathBuf> {
match self.request(PluginRequest::DocumentPath { tab_id }) {
Ok(PluginResponse::DocumentPath(Some(path))) => Some(std::path::PathBuf::from(path)),
Ok(PluginResponse::DocumentPath(None)) => None,
Ok(other) => {
eprintln!("[plugin] unexpected DocumentPath response: {other:?}");
None
}
Err(e) => {
eprintln!("[plugin] DocumentPath request failed: {e}");
None
}
}
}
fn system_variable(&self, name: &str) -> Option<HostSettingValue> {
match self.request(PluginRequest::GetSystemVariable { name: name.to_owned() }) {
Ok(PluginResponse::SystemVariable(value)) => value,
_ => None,
}
}
fn set_system_variable(
&mut self,
name: &str,
value: HostSettingValue,
) -> Result<HostSettingValue, String> {
match self.request(PluginRequest::SetSystemVariable {
name: name.to_owned(),
value,
}) {
Ok(PluginResponse::SystemVariableResult(result)) => {
if result.is_ok() {
self.document_cache = OnceCell::new();
}
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected system variable response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn update_entities_transaction(
&mut self,
label: &str,
entities: Vec<EntityType>,
) -> Result<(), String> {
match self.request(PluginRequest::UpdateEntitiesTransaction {
label: label.to_owned(), entities,
}) {
Ok(PluginResponse::EntityTransactionResult(result)) => {
if result.is_ok() { self.document_cache = OnceCell::new(); }
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected entity transaction response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn selection(&self) -> Vec<Handle> {
match self.request(PluginRequest::GetSelection) {
Ok(PluginResponse::Selection(handles)) => handles,
_ => Vec::new(),
}
}
fn solid_operation(&mut self, operation: crate::host::SolidOperation) -> Result<Handle, String> {
match self.request(PluginRequest::SolidOperation { operation }) {
Ok(PluginResponse::SolidResult(result)) => {
if result.is_ok() { self.document_cache = OnceCell::new(); }
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected solid operation response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn run_command(&mut self, request: crate::host::CommandRequest) -> Result<crate::host::CommandOutcome, String> {
match self.request(PluginRequest::RunCommand { request }) {
Ok(PluginResponse::CommandResult(result)) => {
self.document_cache = OnceCell::new();
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected command response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn table_operation(&mut self, operation: crate::host::TableOperation) -> Result<Handle, String> {
match self.request(PluginRequest::TableOperation { operation }) {
Ok(PluginResponse::TableResult(result)) => {
if result.is_ok() { self.document_cache = OnceCell::new(); }
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected table operation response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn set_selection(&mut self, handles: &[Handle]) -> Result<(), String> {
match self.request(PluginRequest::SetSelection { handles: handles.to_vec() }) {
Ok(PluginResponse::SelectionResult(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected selection response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn add_layer(&mut self, config: crate::host::LayerConfig) -> Option<Handle> {
match self.request(PluginRequest::AddLayer(config)) {
Ok(PluginResponse::OptHandle(h)) => {
if h.is_some() {
self.document_cache = OnceCell::new();
}
h
}
Ok(other) => {
eprintln!("[plugin] unexpected AddLayer response: {other:?}");
None
}
Err(e) => {
eprintln!("[plugin] AddLayer request failed: {e}");
None
}
}
}
fn modify_layer(&mut self, config: crate::host::LayerConfig) -> bool {
match self.request(PluginRequest::ModifyLayer(config)) {
Ok(PluginResponse::Bool(b)) => {
if b {
self.document_cache = OnceCell::new();
}
b
}
Ok(other) => {
eprintln!("[plugin] unexpected ModifyLayer response: {other:?}");
false
}
Err(e) => {
eprintln!("[plugin] ModifyLayer request failed: {e}");
false
}
}
}
fn execute_command(&mut self, cmd: &str) -> bool {
match self.request(PluginRequest::ExecuteCommand(cmd.to_string())) {
Ok(PluginResponse::Bool(b)) => b,
Ok(other) => {
eprintln!("[plugin] unexpected ExecuteCommand response: {other:?}");
false
}
Err(e) => {
eprintln!("[plugin] ExecuteCommand failed: {e}");
false
}
}
}
}
#[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(HashMap::new()),
});
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::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 crate::test_lock::ENV_LOCK;
use codec::entities::Point;
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,
tab_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,
tab_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 concurrent_requests_match_out_of_order_responses() {
let (host_stream, mut runner_stream) = connect_pair();
let client = V4Client::from_stream_for_test(host_stream);
let sender = Arc::new(V4PluginRequestSender {
shared: Arc::clone(&client.shared),
tab_id: 42,
});
let runner = thread::spawn(move || {
let mut requests = Vec::new();
for _ in 0..2 {
match recv::<PluginToHostV4>(&mut runner_stream).unwrap() {
PluginToHostV4::Request {
id,
tab_id: Some(42),
payload: PluginRequest::PushInfo(message),
} => requests.push((id, message)),
other => panic!("unexpected: {other:?}"),
}
}
for (id, message) in requests.into_iter().rev() {
send(
&mut runner_stream,
&HostToPluginV4::Response {
id,
payload: PluginResponse::Bool(message == "first"),
},
)
.unwrap();
}
});
let first_sender = Arc::clone(&sender);
let first = thread::spawn(move || {
first_sender
.request(PluginRequest::PushInfo("first".to_string()))
.unwrap()
});
let second = thread::spawn(move || {
sender
.request(PluginRequest::PushInfo("second".to_string()))
.unwrap()
});
assert!(matches!(first.join().unwrap(), PluginResponse::Bool(true)));
assert!(matches!(second.join().unwrap(), PluginResponse::Bool(false)));
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:?}"),
}
}
}