forked from 1b-agent/opencadstudio-upstream
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>
853 lines
32 KiB
Rust
853 lines
32 KiB
Rust
//! Private V4 connection owned by [`PluginProcess`](crate::process::PluginProcess).
|
|
|
|
use std::collections::VecDeque;
|
|
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
use std::sync::{mpsc, Arc, Mutex, TryLockError};
|
|
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, PluginRequest};
|
|
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::{request_kind, request_timeout, PluginError};
|
|
|
|
type DeferredRequest = (u64, Option<u64>, Box<PluginRequest>);
|
|
|
|
pub(crate) struct V4Connection {
|
|
shared: Arc<V4HostShared>,
|
|
incoming: Mutex<mpsc::Receiver<HostIncoming>>,
|
|
deferred: Mutex<VecDeque<DeferredRequest>>,
|
|
call_lock: Mutex<()>,
|
|
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),
|
|
deferred: Mutex::new(VecDeque::new()),
|
|
call_lock: Mutex::new(()),
|
|
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 _call_guard = self.call_lock.lock().unwrap_or_else(|e| e.into_inner());
|
|
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,
|
|
tab_id,
|
|
payload,
|
|
}) => {
|
|
if tab_id.is_none_or(|request_tab| request_tab == host.tab_id()) {
|
|
self.respond_to_plugin_request(
|
|
host,
|
|
rid,
|
|
payload,
|
|
on_start_interactive,
|
|
)?;
|
|
} else {
|
|
self.deferred
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.push_back((rid, tab_id, payload));
|
|
}
|
|
}
|
|
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 _call_guard = match self.call_lock.try_lock() {
|
|
Ok(guard) => guard,
|
|
Err(TryLockError::Poisoned(error)) => error.into_inner(),
|
|
Err(TryLockError::WouldBlock) => return Ok(()),
|
|
};
|
|
let current_tab_id = host.tab_id();
|
|
let mut ready = VecDeque::new();
|
|
{
|
|
let mut deferred = self.deferred.lock().unwrap_or_else(|e| e.into_inner());
|
|
let mut waiting = VecDeque::new();
|
|
while let Some((id, tab_id, payload)) = deferred.pop_front() {
|
|
if tab_id.is_none_or(|request_tab| request_tab == current_tab_id) {
|
|
ready.push_back((id, payload));
|
|
} else {
|
|
waiting.push_back((id, tab_id, payload));
|
|
}
|
|
}
|
|
*deferred = waiting;
|
|
}
|
|
while let Some((id, payload)) = ready.pop_front() {
|
|
self.respond_to_plugin_request(host, id, payload, on_start_interactive)?;
|
|
}
|
|
|
|
let incoming = self.incoming.lock().unwrap_or_else(|e| e.into_inner());
|
|
loop {
|
|
match incoming.try_recv() {
|
|
Ok(HostIncoming::Request {
|
|
id,
|
|
tab_id,
|
|
payload,
|
|
}) => {
|
|
if tab_id.is_none_or(|request_tab| request_tab == current_tab_id) {
|
|
self.respond_to_plugin_request(
|
|
host,
|
|
id,
|
|
payload,
|
|
on_start_interactive,
|
|
)?;
|
|
} else {
|
|
self.deferred
|
|
.lock()
|
|
.unwrap_or_else(|e| e.into_inner())
|
|
.push_back((id, tab_id, payload));
|
|
}
|
|
}
|
|
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(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn respond_to_plugin_request(
|
|
&self,
|
|
host: &mut dyn HostApi,
|
|
id: u64,
|
|
payload: Box<PluginRequest>,
|
|
on_start_interactive: &mut dyn FnMut(u64),
|
|
) -> Result<(), PluginError> {
|
|
let response = 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: response,
|
|
},
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// 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. The host process
|
|
// will kill the runner afterwards, so a long wait only delays shutdown
|
|
// without improving cleanup.
|
|
let deadline = Instant::now() + Duration::from_millis(100);
|
|
while self.shared.alive.load(Ordering::SeqCst) && Instant::now() < deadline {
|
|
std::thread::sleep(Duration::from_millis(10));
|
|
}
|
|
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 codec::{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 {
|
|
push_info_messages: StdMutex<Vec<String>>,
|
|
transactions: StdMutex<Vec<(String, Vec<codec::EntityType>)>>,
|
|
}
|
|
impl DummyHost {
|
|
fn new() -> Self {
|
|
Self {
|
|
push_info_messages: StdMutex::new(Vec::new()),
|
|
transactions: StdMutex::new(Vec::new()),
|
|
}
|
|
}
|
|
fn take_push_info(&self) -> Vec<String> {
|
|
std::mem::take(&mut *self.push_info_messages.lock().unwrap())
|
|
}
|
|
fn take_transactions(&self) -> Vec<(String, Vec<codec::EntityType>)> {
|
|
std::mem::take(&mut *self.transactions.lock().unwrap())
|
|
}
|
|
}
|
|
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: codec::EntityType) -> Handle {
|
|
panic!("not used")
|
|
}
|
|
fn bump_geometry(&mut self) {}
|
|
fn read_record(
|
|
&self,
|
|
_handle: Handle,
|
|
_app_name: &str,
|
|
) -> Option<&codec::xdata::ExtendedDataRecord> {
|
|
None
|
|
}
|
|
fn write_record(
|
|
&mut self,
|
|
_handle: Handle,
|
|
_record: codec::xdata::ExtendedDataRecord,
|
|
) -> bool {
|
|
false
|
|
}
|
|
fn remove_record(&mut self, _handle: Handle, _app_name: &str) -> bool {
|
|
false
|
|
}
|
|
fn push_undo(&mut self, _label: &str) {}
|
|
fn update_entities_transaction(
|
|
&mut self,
|
|
label: &str,
|
|
entities: Vec<codec::EntityType>,
|
|
) -> Result<(), String> {
|
|
self.transactions.lock().unwrap().push((label.to_owned(), entities));
|
|
Ok(())
|
|
}
|
|
fn set_dirty(&mut self) {}
|
|
fn push_info(&mut self, msg: &str) {
|
|
self.push_info_messages.lock().unwrap().push(msg.to_string());
|
|
}
|
|
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;
|
|
|
|
use crate::test_lock::ENV_LOCK;
|
|
|
|
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();
|
|
let prev_call_timeout = std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS").ok();
|
|
let prev_test_floor = std::env::var("OCS_PLUGIN_TEST_FLOOR_SECS").ok();
|
|
let prev_execute_timeout = std::env::var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS").ok();
|
|
// Remove the test floor bypass and the call-timeout override so the
|
|
// ExecuteCode-specific floor (60 s) is actually exercised.
|
|
std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS");
|
|
std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS");
|
|
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"
|
|
);
|
|
|
|
match prev_call_timeout {
|
|
Some(v) => std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", v),
|
|
None => std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"),
|
|
}
|
|
match prev_test_floor {
|
|
Some(v) => std::env::set_var("OCS_PLUGIN_TEST_FLOOR_SECS", v),
|
|
None => std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS"),
|
|
}
|
|
match prev_execute_timeout {
|
|
Some(v) => std::env::set_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS", v),
|
|
None => 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::new();
|
|
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,
|
|
tab_id: None,
|
|
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::new();
|
|
let resp = conn
|
|
.call(&mut host, HostRequest::Dispatch { cmd: "NESTED".to_string() }, &mut |_| {})
|
|
.unwrap();
|
|
assert!(matches!(resp, HostResponse::Bool(true)));
|
|
let infos = host.take_push_info();
|
|
assert_eq!(infos, vec!["nested".to_string()], "push_info should be delivered to host");
|
|
runner.join().unwrap();
|
|
restore_test_env();
|
|
}
|
|
|
|
#[test]
|
|
fn v4_connection_routes_entity_transaction_over_nested_ipc() {
|
|
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 mut point = codec::entities::Point::new();
|
|
point.common.handle = Handle::new(42);
|
|
point.location.x = 7.0;
|
|
let expected = codec::EntityType::Point(point);
|
|
let sent = expected.clone();
|
|
|
|
let runner = thread::spawn(move || {
|
|
let req = recv::<HostToPluginV4>(&mut runner_stream).unwrap();
|
|
let HostToPluginV4::Request { id, payload: HostRequest::Dispatch { .. } } = req else {
|
|
panic!("unexpected host request: {req:?}");
|
|
};
|
|
send(&mut runner_stream, &PluginToHostV4::Request {
|
|
id: 99,
|
|
tab_id: Some(0),
|
|
payload: PluginRequest::UpdateEntitiesTransaction {
|
|
label: "Move point".into(),
|
|
entities: vec![sent],
|
|
},
|
|
}).unwrap();
|
|
assert!(matches!(recv::<HostToPluginV4>(&mut runner_stream).unwrap(),
|
|
HostToPluginV4::Response { id: 99, payload: PluginResponse::EntityTransactionResult(Ok(())) }));
|
|
send(&mut runner_stream, &PluginToHostV4::Response {
|
|
id,
|
|
payload: HostResponse::Bool(true),
|
|
}).unwrap();
|
|
});
|
|
|
|
let mut host = DummyHost::new();
|
|
assert!(matches!(conn.call(&mut host, HostRequest::Dispatch { cmd: "MOVE".into() }, &mut |_| {}).unwrap(),
|
|
HostResponse::Bool(true)));
|
|
assert_eq!(host.take_transactions(), vec![("Move point".into(), vec![expected])]);
|
|
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::new();
|
|
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();
|
|
}
|
|
}
|