Snapshot: V4 plugin API and host integration

This commit is contained in:
Sebastian committed 2026-08-12 21:01:39 +02:00
1 parent 448869ac35
commit 29c41a4041
36 files changed
+5463 -253

No files matched your search

+77 -15
View File
@@ -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();
+96 -5
View File
@@ -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:?}"),
}
}
}
+34 -5
View File
@@ -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),
}
+340
View File
@@ -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));
}
}
+14 -1
View File
@@ -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()),
}
}
+16 -2
View File
@@ -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)?)
}
+913
View File
@@ -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:?}"),
}
}
}
+10
View File
@@ -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:?}"),
}
}
}
+164
View File
@@ -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());
}
}