Files
OneCAD-Draft/crates/ocs_plugin_api/src/ipc/mod.rs
T
Sebastian 60eb7b086e feat(plugin-api): entity-class display declarations on the manifest (API v10)
Plugins can now declare the entity classes they identify via XDATA, and the
host resolves them to display-only type labels while the declaring plugin is
loaded.

- Append `BuiltinPlugin::entity_classes()` (a guarded trailing vtable slot,
  the same additive pattern as `on_load` in v5) returning a &'static
  `EntityClasses { app_id, class_prefix, labels }`. The static
  `PluginManifest` struct is deliberately untouched: older cdylibs share it
  by pointer and its layout is frozen ABI since v1, so adding a field would
  read past their allocation.
- `OwnedEntityClasses` travels on the registered manifest as
  `OwnedPluginManifest.entity_classes` with `#[serde(default)]`, so manifests
  serialized before the field keep decoding (older runners tolerate it
  both ways).
- The runner folds the declaration into the GetManifest registration only
  when the loaded cdylib's exported `ocs_plugin_api_version` symbol reports
  >= 10 — an older binary's vtable predates the slot, so it is never called;
  its entities simply keep their native type names.
- `PluginManager::entity_class_declarations` exposes the live set, filtered
  to alive, non-disabled plugins in load order. The registered manifests are
  the only source and nothing is cached: not installed, disabled,
  cap-excluded (`OCS_PLUGIN_MAX_API_VERSION`), failed at load, removed, or a
  dead process all contribute nothing.
- Embedded version info `api_version` follows the bump to 10;
  `API_VERSION_MIN_SUPPORTED` stays 3.

Tests: runner fold (v10 folds, pre-v10 skipped, `entity_classes()` panic is
contained and reported), manager filtering (disabled / dead / undeclaring
skipped; declarations disappear once the process dies — verified with a real
killed child), bincode registration round-trip, and older-manifest serde
default.
2026-10-15 22:39:09 +02:00

303 lines
11 KiB
Rust

//! Inter-process communication layer for out-of-process plugins.
//!
//! Built only with the `host` feature because it needs `opencadcodec`-typed
//! messages and the plugin runner binary.
//!
//! Submodules:
//!
//! - [`protocol`](crate::ipc::protocol) — V2/V3 request/response enums and the
//! initial runner handshake.
//! - [`transport`](crate::ipc::transport) — length-framed bincode over a local
//! socket; enforces `MAX_MESSAGE_SIZE`.
//! - [`client`](crate::ipc::client) — plugin-side V2/V3 client and `HostApi`
//! proxy.
//! - [`server`](crate::ipc::server) — host-side handler that applies a
//! `PluginRequest` to a `HostApi` implementation.
//! - [`v4`](crate::ipc::v4) — multiplexed V4 frames, notifications, and
//! correlation ids.
//! - [`proxy`](crate::ipc::proxy) — optional TCP proxy so plugin child
//! processes can forward requests to the host.
#[cfg(feature = "host")]
pub mod client;
#[cfg(feature = "host")]
pub mod protocol;
#[cfg(feature = "host")]
pub mod proxy;
#[cfg(feature = "host")]
pub mod server;
#[cfg(feature = "host")]
pub mod transport;
#[cfg(feature = "host")]
pub mod v4;
#[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 crate::ipc::protocol::{HostRequest, HostResponse, HostToPlugin, PluginToHost};
use crate::ipc::transport::{recv, send};
fn unique_socket_name() -> String {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
format!("ocs_plugin_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 namespaced name");
let listener = ListenerOptions::new()
.name(name_ref)
.create_sync()
.expect("create listener");
let client = thread::spawn(move || {
StreamTrait::connect(name.to_ns_name::<GenericNamespaced>().unwrap()).expect("connect")
});
let server = listener.accept().expect("accept");
let client = client.join().expect("client thread");
(server, client)
}
#[test]
fn transport_round_trips_host_request() {
let (mut a, mut b) = connect_pair();
let req = HostRequest::Dispatch {
cmd: "LINE".to_string(),
};
send(&mut a, &HostToPlugin::Request(req)).unwrap();
let got = recv::<HostToPlugin>(&mut b).unwrap();
match got {
HostToPlugin::Request(req) => match req {
HostRequest::Dispatch { cmd } => assert_eq!(cmd, "LINE"),
other => panic!("unexpected: {other:?}"),
},
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn transport_round_trips_plugin_request() {
let (mut a, mut b) = connect_pair();
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(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:?}"),
}
}
#[test]
fn registered_manifest_roundtrips_entity_classes_over_bincode() {
// The registration handshake is length-framed bincode, so the manifest
// and its API-v10 entity-class declaration must survive exactly that
// wire format.
let manifest = crate::ribbon::owned::OwnedPluginManifest {
id: "opencad.civil_ifc".to_string(),
name: "Civil IFC".to_string(),
version: "0.1.0".to_string(),
description: "test".to_string(),
api_version: 10,
ribbon_order: 50,
xdata_apps: vec!["CVIFC".to_string()],
command_prefixes: vec![],
entity_classes: Some(crate::ribbon::owned::OwnedEntityClasses {
app_id: "CVIFC".to_string(),
class_prefix: "CVIFC-CLASS:".to_string(),
labels: [
("LINE".to_string(), "Linear Element".to_string()),
(
"CIRCULARARC".to_string(),
"Circular Arc Element".to_string(),
),
]
.into_iter()
.collect(),
}),
};
let resp = HostResponse::Manifest(manifest);
let bytes = bincode::serialize(&resp).unwrap();
let got: HostResponse = bincode::deserialize(&bytes).unwrap();
match got {
HostResponse::Manifest(m) => {
let classes = m.entity_classes.expect("declaration survives");
assert_eq!(classes.app_id, "CVIFC");
assert_eq!(classes.class_prefix, "CVIFC-CLASS:");
assert_eq!(
classes.labels.get("LINE").map(String::as_str),
Some("Linear Element")
);
}
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn manifest_entity_classes_default_when_absent() {
// A manifest serialized before the field existed (an older runner)
// decodes with the declaration defaulting to `None`.
let older = r#"{
"id": "old.plugin",
"name": "Old",
"version": "0.1.0",
"description": "test",
"api_version": 9,
"ribbon_order": 0,
"xdata_apps": [],
"command_prefixes": []
}"#;
let m: crate::ribbon::owned::OwnedPluginManifest =
serde_json::from_str(older).expect("older manifest decodes");
assert_eq!(m.id, "old.plugin");
assert!(m.entity_classes.is_none());
}
#[test]
fn v4_additions_preserve_legacy_discriminants() {
fn discriminant<T: serde::Serialize>(value: &T) -> u32 {
let bytes = bincode::serialize(value).unwrap();
u32::from_le_bytes(bytes[..4].try_into().unwrap())
}
assert_eq!(discriminant(&HostRequest::Shutdown), 6);
assert_eq!(discriminant(&HostResponse::Error(String::new())), 5);
assert_eq!(
discriminant(&crate::ipc::protocol::PluginRequest::BumpGeometry),
6
);
assert_eq!(
discriminant(&crate::ipc::protocol::PluginResponse::Record(None)),
3
);
}
#[test]
fn transport_rejects_oversized_message() {
let (mut a, _b) = connect_pair();
// A Vec<u8> larger than MAX_MESSAGE_SIZE should be rejected on send.
let huge = vec![0u8; 65 * 1024 * 1024];
let err = send(&mut a, &huge).unwrap_err();
assert!(format!("{err}").contains("too large"));
}
#[test]
fn protocol_host_response_serde_roundtrip() {
let resp = HostResponse::Text("pick a point".to_string());
let bytes = bincode::serialize(&resp).unwrap();
let got: HostResponse = bincode::deserialize(&bytes).unwrap();
match got {
HostResponse::Text(s) => assert_eq!(s, "pick a point"),
other => panic!("unexpected: {other:?}"),
}
}
#[test]
fn protocol_plugin_to_host_serde_roundtrip() {
let msg = PluginToHost::Response(HostResponse::Bool(true));
let bytes = bincode::serialize(&msg).unwrap();
let got: PluginToHost = bincode::deserialize(&bytes).unwrap();
match got {
PluginToHost::Response(HostResponse::Bool(b)) => assert!(b),
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:?}"),
}
}
}