From 29c41a4041916205e20b87fbfef4977260ab90bc Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:39:51 +0200 Subject: [PATCH] Snapshot: V4 plugin API and host integration --- Cargo.toml | 1 + crates/ocs_plugin_api/Cargo.toml | 30 +- crates/ocs_plugin_api/README.md | 426 ++++++++ crates/ocs_plugin_api/build.rs | 385 ++++++++ crates/ocs_plugin_api/src/host.rs | 412 +++++++- crates/ocs_plugin_api/src/host_v4.rs | 91 ++ crates/ocs_plugin_api/src/ipc/client.rs | 92 +- crates/ocs_plugin_api/src/ipc/mod.rs | 101 +- crates/ocs_plugin_api/src/ipc/protocol.rs | 39 +- crates/ocs_plugin_api/src/ipc/proxy.rs | 340 +++++++ crates/ocs_plugin_api/src/ipc/server.rs | 15 +- crates/ocs_plugin_api/src/ipc/transport.rs | 18 +- crates/ocs_plugin_api/src/ipc/v4/client.rs | 913 ++++++++++++++++++ crates/ocs_plugin_api/src/ipc/v4/mod.rs | 10 + crates/ocs_plugin_api/src/ipc/v4/protocol.rs | 127 +++ crates/ocs_plugin_api/src/ipc/v4/server.rs | 164 ++++ crates/ocs_plugin_api/src/lib.rs | 24 +- crates/ocs_plugin_api/src/manifest.rs | 80 +- crates/ocs_plugin_api/src/process.rs | 646 ++++++++++--- crates/ocs_plugin_api/src/process/manager.rs | 74 +- crates/ocs_plugin_api/src/process/v4.rs | 743 ++++++++++++++ crates/ocs_plugin_api/src/runner.rs | 307 +++++- crates/ocs_plugin_api/src/shm.rs | 193 +++- crates/ocs_plugin_api/src/type_registry.rs | 88 ++ .../ocs_plugin_api/src/type_registry_types.rs | 77 ++ crates/ocs_plugin_api/src/version_info.rs | 57 ++ src/app/mod.rs | 3 + src/app/plugin_host.rs | 85 +- src/app/update/command.rs | 7 + src/app/update/mod.rs | 15 + src/app/view/mod.rs | 13 + src/plugin/external.rs | 17 +- src/plugin/marketplace.rs | 7 +- src/plugin/mod.rs | 2 + src/plugin/v4_support.rs | 61 ++ src/scene/entity.rs | 53 +- 36 files changed, 5463 insertions(+), 253 deletions(-) create mode 100644 crates/ocs_plugin_api/README.md create mode 100644 crates/ocs_plugin_api/build.rs create mode 100644 crates/ocs_plugin_api/src/host_v4.rs create mode 100644 crates/ocs_plugin_api/src/ipc/proxy.rs create mode 100644 crates/ocs_plugin_api/src/ipc/v4/client.rs create mode 100644 crates/ocs_plugin_api/src/ipc/v4/mod.rs create mode 100644 crates/ocs_plugin_api/src/ipc/v4/protocol.rs create mode 100644 crates/ocs_plugin_api/src/ipc/v4/server.rs create mode 100644 crates/ocs_plugin_api/src/process/v4.rs create mode 100644 crates/ocs_plugin_api/src/type_registry.rs create mode 100644 crates/ocs_plugin_api/src/type_registry_types.rs create mode 100644 crates/ocs_plugin_api/src/version_info.rs create mode 100644 src/plugin/v4_support.rs diff --git a/Cargo.toml b/Cargo.toml index 38101daaa..70e3a17ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -64,6 +64,7 @@ ashpd = { version = "0.13.13", default-features = false, features = ["async-io", [patch.crates-io] iced_core = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } iced_widget = { git = "https://github.com/iced-rs/iced.git", rev = "23604ff22ab0aad9e00b9327cb7b8546ed84db39" } +acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4736846" } [target.'cfg(not(target_arch = "wasm32"))'.dependencies] ocs_plugin_api = { path = "crates/ocs_plugin_api", features = ["host"] } diff --git a/crates/ocs_plugin_api/Cargo.toml b/crates/ocs_plugin_api/Cargo.toml index da8c6bd39..7573a1e06 100644 --- a/crates/ocs_plugin_api/Cargo.toml +++ b/crates/ocs_plugin_api/Cargo.toml @@ -1,19 +1,28 @@ [package] name = "ocs_plugin_api" -version = "0.1.0" +version = "0.2.0" edition = "2021" description = "Stable, dependency-free contract for Open CAD Studio add-on packages: plugin manifest, version handshake, and ribbon/CadModule types." license = "GPL-3.0-only" [dependencies] +# Serde is used unconditionally by the build-time-generated embedded type +# registry types (`type_registry_types.rs`). The host feature no longer needs +# to enable it explicitly. +serde = { version = "1", features = ["derive"] } + # Pulled in only by the `host` feature, which adds the `acadrust`-typed # `HostApi` runtime surface. The default crate stays dependency-free so engine # crates and external tooling can depend on the manifest/ribbon contract cheaply. -acadrust = { git = "https://github.com/HakanSeven12/cadcodec.git", rev = "4736846", optional = true, features = ["serde"] } +# +# acadrust types appear in the HostApi trait object, so the plugin and host +# MUST resolve the exact same acadrust source. The root Cargo.toml pins the +# exact revision via [patch.crates-io]; this crate uses the same version +# requirement so the patch applies consistently across the workspace. +acadrust = { version = "0.4", optional = true, features = ["serde"] } # Runtime IPC and serialization (host feature only). interprocess = { version = "2", optional = true } -serde = { version = "1", features = ["derive"], optional = true } bincode = { version = "1", optional = true } thiserror = { version = "1", optional = true } libloading = { version = "0.8", optional = true } @@ -25,4 +34,17 @@ getrandom = { version = "0.2", optional = true } # Enables the runtime host surface (`HostApi` trait) and the out-of-process # plugin runtime. The OpenCADStudio binary turns this on; pure-data consumers # leave it off. -host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"] +host = ["dep:acadrust", "dep:interprocess", "dep:bincode", "dep:thiserror", "dep:libloading", "dep:memmap2", "dep:rkyv", "dep:getrandom"] + +[build-dependencies] +serde-reflection = "0.6" +serde_json = "1" +serde = { version = "1", features = ["derive"] } +cargo-lock = "11" +# acadrust is scanned at build time to generate the embedded type registry. +# Use the same version requirement as the root Cargo.toml so the +# [patch.crates-io] override applies here too. +acadrust = { version = "0.4", features = ["serde"] } + +[dev-dependencies] +serde_json = "1" diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md new file mode 100644 index 000000000..939518162 --- /dev/null +++ b/crates/ocs_plugin_api/README.md @@ -0,0 +1,426 @@ +# `ocs_plugin_api` + +Versioned, out-of-process plugin API for Open CAD Studio. This crate defines the +contract between the host CAD application and third-party plugins. It is +designed to stay small and dependency-free in its default configuration so that +plugin crates and external tooling can depend on the manifest/ribbon surface +cheaply. The runtime host surface is enabled by the `host` feature. + +## Plugin Architecture + +OCS is scriptable and extensible through a versioned plugin API. The API +supports three major protocol generations: **V2**, **V3**, and **V4**. Each +generation keeps the previous ABI stable by appending new vtable entries and new +enum variants at the end, so older plugins continue to load on newer hosts. + +| Version | Delivery model | Key capability | ABI / wire change | +|---------|----------------|----------------|-------------------| +| **V2** | Out-of-process runner | Synchronous commands + interactive point collection (`HostApi::start_interactive`) | Baseline | +| **V3** | Out-of-process runner | Local cached `document()`/`document_mut()` + shared-memory `DocumentReader`/`document_view` for large reads | New vtable entries appended; new `PluginRequest`/`PluginResponse` variants appended | +| **V4** | Out-of-process runner over a multiplexed local socket | Full-duplex notifications + asynchronous REPL `ExecuteCode` | New `HostToPluginV4`/`PluginToHostV4` frame layer; `BuiltinPlugin::on_notification` and `start_execute_code` appended to trait | + +The host controls which generations are accepted at runtime through +`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `3` to disable V4). The +current host advertises [`API_VERSION = 4`](src/manifest.rs) and supports plugins +back to `API_VERSION_MIN_SUPPORTED = 2`. + +### High-level layout + +```mermaid +flowchart LR + subgraph HostProcess["Host process"] + App[OpenCADStudio] + PM[PluginManager] + PP[PluginProcess] + HA[HostApi impl] + end + subgraph RunnerProcess["Runner process"] + RL[Runner loop] + V4C[V4Client] + Proxy[HostApi proxy] + end + subgraph Library["Plugin cdylib"] + BP[BuiltinPlugin impl] + end + + App --> PM + PM --> PP + PP -- local socket --> RL + RL --> V4C + V4C --> Proxy + Proxy --> BP + PP -. document view / shared memory .-> HA +``` + +A plugin is a Rust `cdylib` (or any language exposing the C symbols) that: + +1. Exports `ocs_plugin_api_version()` — returns the API major the plugin was + built against. +2. Exports `ocs_plugin_register()` — returns a `Box`. +3. Implements `BuiltinPlugin` (manifest, ribbon, dispatch, optional REPL + execution, optional notifications). + +The host does **not** load the plugin into its own address space. It re-executes +itself as `--ocs-runner ` and talks to the runner over a +local socket. This isolates plugin crashes from the CAD process. + +### Version handshake + +```mermaid +sequenceDiagram + participant H as Host + participant R as Runner process + participant L as Plugin cdylib + + H->>R: spawn --ocs-runner path token + R->>L: dlopen, ocs_plugin_register() + L-->>R: Box + R->>H: V3 or V4 handshake token + H->>R: GetManifest + R-->>H: PluginManifest { api_version } + alt api_version accepted + H->>R: Dispatch / ExecuteCode / InteractiveEvent ... + else api_version rejected + H->>R: Shutdown + end +``` + +### V2/V3 wire protocol + +V2 and V3 share the same request/response framing. The host sends a +`HostToPlugin::Request(HostRequest::*)`; the runner executes it and returns a +`PluginToHost::Response(HostResponse::*)`. Nested plugin-to-host requests (for +example `add_entity`) are allowed inside a dispatch: the runner creates a +temporary `HostApi` proxy, forwards the `PluginRequest`, awaits the host reply, +and resumes the original call. + +```mermaid +sequenceDiagram + participant H as Host + participant R as V2/V3 Runner + participant P as BuiltinPlugin + + H->>R: HostToPlugin::Request(Dispatch { cmd: "LINE" }) + R->>P: dispatch(host, "LINE") + P->>R: PluginToHost::Request(AddEntity(...)) + R->>H: forward AddEntity request + H-->>R: handle + R-->>P: handle + P-->>R: true + R-->>H: PluginToHost::Response(Bool(true)) +``` + +### V4 wire protocol + +V4 replaces the simple request/response pipe with a multiplexed local socket. +The frame format supports: + +- Host → runner requests (`HostToPluginV4::Request { id, payload: HostRequest }`) +- Runner → host responses (`PluginToHostV4::Response { id, payload: HostResponse }`) +- Bi-directional, best-effort notifications (`NotificationEnvelope`) + +Because the socket is full-duplex, the host can push +`HostNotification::DocumentChanged` or `InputLine` to a plugin at any time, and +the plugin can stream `PluginNotification::Output`/`Progress`/`Log` back while a +long command is still running. + +```mermaid +sequenceDiagram + participant H as Host + participant R as V4 Runner + participant P as BuiltinPlugin + + H->>R: Request(id=7, ExecuteCode { code: "1+1", tab_index: 2 }) + R->>P: start_execute_code(host, 7, "1+1", Editor, respond) + P-->>R: true (async) + Note over P: spawned on worker thread + P->>R: Notification(Output { text: "calculating..." }) + R->>H: forward Output + P->>R: Response(id=7, CodeExecutionResult { success: true, output: "2" }) + R->>H: delayed response +``` + +### V4 asynchronous REPL execution + +V4 introduces `BuiltinPlugin::start_execute_code`. It is the plugin's entry point +for REPL-style code evaluation. The signature receives the active tab's +`HostApi` so the REPL session is tied to the document tab that invoked it, plus a +`respond` callback that the plugin calls when evaluation finishes (possibly from a +background thread). + +V4 also introduces `PluginRequestSender`: a thread-safe, `'static` interface that +lets a plugin send `PluginRequest`s back to the host from any thread. This is +useful when the plugin spawns a background worker (for example, the Python REPL's +mutation thread) that needs to read the document snapshot or apply mutations +without blocking the runner loop. Obtain it from `HostApi::plugin_request_sender()`; +it is only available when the plugin is hosted out-of-process by a V4 runner. + +```rust +use ocs_plugin_api::host::{ + BuiltinPlugin, CommandSource, ExecutionResult, HostApi, execute_code_guard, + PluginRequestSender, +}; +use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; + +struct ReplPlugin; + +impl BuiltinPlugin for ReplPlugin { + // ... manifest, ribbon, dispatch ... + + fn start_execute_code( + &mut self, + host: &mut dyn HostApi, + _command_id: u64, + code: &str, + _source: CommandSource, + respond: Box, + ) -> bool { + let tab = host.tab_index(); + let code = code.to_string(); + + // A V4 host can provide a sender for background threads. + let sender: Option> = host.plugin_request_sender().map(Arc::from); + + execute_code_guard(respond, move || { + // Long-running evaluation happens here without blocking the runner loop. + // If a sender is available, worker threads can use it to call back into + // the host, e.g. to refresh the document snapshot. + let result = evaluate(&code, tab, sender.as_deref()); + ExecutionResult { + success: result.is_ok(), + output: result.ok(), + error: result.err(), + error_type: None, + traceback: None, + line_number: None, + column_number: None, + duration_ms: 0.0, + } + }); + true + } +} +``` + +```rust +// Used from a background thread. +fn refresh_snapshot(sender: &dyn PluginRequestSender) -> Result> { + match sender.request(PluginRequest::OpenDocumentView)? { + PluginResponse::DocumentView { path, version } => Ok(path), + other => Err(format!("unexpected response: {other:?}").into()), + } +} +``` + +The host side calls it synchronously but with a long timeout: + +```rust +use ocs_plugin_api::host::CommandSource; +use ocs_plugin_api::process::{PluginError, PluginProcess}; + +fn run_editor_snippet( + plugin: &PluginProcess, + host: &mut dyn HostApi, +) -> Result { + let result = plugin.execute_code(host, 42, CommandSource::Editor, "1+1")?; + Ok(result.output.unwrap_or_default()) +} +``` + +`PluginProcess::execute_code` reads `host.tab_index()` and forwards it in the +`ExecuteCode` request, so the plugin always knows which document tab the REPL +session belongs to. + +### Embedded build-time metadata + +`ocs_plugin_api` embeds two JSON blobs at build time: + +- **Type registry** — a stable, language-binding-friendly schema generated by + tracing a curated allow-list of `acadrust` types with `serde-reflection`. +- **Version info** — `ocs_version`, `ocs_plugin_api_version`, `acadrust_version`, + `api_version`, etc. + +Access them without pulling the `host` feature: + +```rust +let registry_json = ocs_plugin_api::get_embedded_type_registry_json(); +let version_json = ocs_plugin_api::get_embedded_version_info_json(); +``` + +### Minimal plugin example + +```rust +use ocs_plugin_api::export_plugin; +use ocs_plugin_api::host::{BuiltinPlugin, HostApi, PluginManifest}; +use ocs_plugin_api::manifest::ApiVersion; +use ocs_plugin_api::ribbon::{ + CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef, +}; + +static MANIFEST: PluginManifest = PluginManifest { + id: "com.example.hello", + name: "Hello Plugin", + version: "0.1.0", + description: "A minimal example plugin.", + api_version: ApiVersion { major: 4 }, + ribbon_order: 100, + xdata_apps: &[], + command_prefixes: &["HELLO"], +}; + +struct HelloModule; +impl CadModule for HelloModule { + fn id(&self) -> &'static str { MANIFEST.id } + fn title(&self) -> &'static str { "Hello" } + fn ribbon_groups(&self) -> &[RibbonGroup] { + static GROUPS: &[RibbonGroup] = &[RibbonGroup { + title: "Example", + tools: vec![RibbonItem::Tool(ToolDef { + id: "HELLO", + label: "Say Hello", + icon: IconKind::Glyph("H"), + event: ModuleEvent::Command("HELLO".to_string()), + })], + }]; + GROUPS + } +} + +struct HelloPlugin; +impl BuiltinPlugin for HelloPlugin { + fn manifest(&self) -> &'static PluginManifest { &MANIFEST } + fn ribbon(&self) -> Box { Box::new(HelloModule) } + fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { + if cmd == "HELLO" { + host.push_info("Hello from the plugin!"); + return true; + } + false + } +} + +export_plugin!(HelloPlugin); +``` + +### Host-side loading example + +```rust +use std::path::Path; +use std::sync::Arc; +use ocs_plugin_api::process::{PluginError, PluginManager}; + +fn main() -> Result<(), PluginError> { + let mut manager = PluginManager::new(); + manager.set_notification_handler(Arc::new(|_id, _cmd, _notif| {})); + + let mut host_api = /* host-provided HostApi implementation */; + let _plugin_id = manager.load( + Path::new("./target/release/libhello_plugin.dll"), + &mut host_api, + )?; + + let result = manager.dispatch(&mut host_api, "HELLO", |_| false); + if result.handled { + println!("Plugin handled HELLO"); + } + + Ok(()) +} +``` + +### V4 shared-memory document view example + +V4 plugins can request a zero-copy snapshot of the active document and mutate it +from a background thread through `PluginRequestSender`. + +```rust +use std::sync::Arc; +use ocs_plugin_api::host::{BuiltinPlugin, HostApi, PluginRequestSender}; +use ocs_plugin_api::ipc::protocol::{PluginRequest, PluginResponse}; +use ocs_plugin_api::shm::DocumentViewDataV4; +use ocs_plugin_api::shm::SharedDocumentReader; + +struct SnapshotWorker { + reader: SharedDocumentReader, + sender: Arc, +} + +impl SnapshotWorker { + fn add_point(&self, x: f64, y: f64, z: f64) -> anyhow::Result { + use acadrust::entities::Point; + use acadrust::EntityType; + + let mut p = Point::from_coords(x, y, z); + p.common.layer = "0".to_string(); + let entity = EntityType::Point(p); + + match self.sender.request(PluginRequest::AddEntity(entity))? { + PluginResponse::Handle(h) => Ok(h.value()), + other => Err(anyhow::anyhow!("unexpected add response: {other:?}")), + } + } +} +``` + +In the `BuiltinPlugin` dispatch method: + +```rust +fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool { + if cmd != "SNAPSHOT_DEMO" { + return false; + } + + let Some(view) = host.document_view_v4(host.tab_id()) else { + host.push_error("host does not support V4 document views"); + return true; + }; + + let Some(sender) = host.plugin_request_sender() else { + host.push_error("host does not provide a V4 request sender"); + return true; + }; + + let reader = SharedDocumentReader::::open(Path::new(&view.path)) + .expect("open snapshot"); + let worker = SnapshotWorker { + reader, + sender: Arc::from(sender), + }; + + std::thread::spawn(move || { + let _ = worker.add_point(10.0, 20.0, 0.0); + }); + true +} +``` + +### V4 request proxy pattern + +When a plugin spawns a child process (for example a Python REPL) that needs to +mutate the host document, the plugin can run a local TCP request proxy and pass +the port to the child. The child connects to the proxy; the proxy forwards +`PluginRequest` frames to the host's `PluginRequestSender` and returns the +responses. + +```rust +use std::net::TcpListener; +use ocs_plugin_api::ipc::proxy::run_request_proxy_with_shutdown; + +let listener = TcpListener::bind(("127.0.0.1", 0)).unwrap(); +let port = listener.local_addr().unwrap().port(); + +let sender: Arc = Arc::from(host.plugin_request_sender().unwrap()); +std::thread::spawn(move || { + let (shutdown_tx, shutdown_rx) = std::sync::mpsc::channel::<()>(); + run_request_proxy_with_shutdown(listener, sender, shutdown_rx).ok(); +}); + +// Pass `port` to the child process via environment variable. +``` + +### Further reading + +- Plugin template: [`../../docs/plugin-template`](../../docs/plugin-template) +- Plugin marketplace registry: [`../../plugins/README.md`](../../plugins/README.md) +- REPL design notes: [`../DESIGN_REPL.md`](../DESIGN_REPL.md) +- Python REPL plugin: [`../ocs_python_repl/README.md`](../ocs_python_repl/README.md) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs new file mode 100644 index 000000000..f7923dc49 --- /dev/null +++ b/crates/ocs_plugin_api/build.rs @@ -0,0 +1,385 @@ +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::path::{Path, PathBuf}; +use std::time::SystemTime; + +use cargo_lock::Lockfile; +use serde::de::DeserializeOwned; +use serde_reflection::{ + ContainerFormat, Format, Named, Registry, Samples, Tracer, TracerConfig, VariantFormat, +}; + +// Include the stable schema types so the same definitions are used at build +// time and at runtime. The file is self-contained and only depends on serde. +include!("src/type_registry_types.rs"); + +fn main() { + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + generate_type_registry(&out_dir); + generate_version_info(&out_dir); + println!("cargo:rerun-if-changed=Cargo.lock"); +} + +// ════════════════════════════════════════════════════════════════════════════ +// Type registry +// ════════════════════════════════════════════════════════════════════════════ + +fn generate_type_registry(out_dir: &Path) { + let mut tracer = Tracer::new(TracerConfig::default()); + let mut samples = Samples::new(); + add_enum_samples(&mut tracer, &mut samples); + + // Curated allow-list of acadrust types. We intentionally exclude + // EntityType here to avoid pulling in all 41+ entity variants and any + // custom-serializer edge cases. + type TraceFn = fn(&mut Tracer, &Samples); + let types: Vec<(&str, TraceFn)> = vec![ + ("Point", trace::), + ("Line", trace::), + ("Circle", trace::), + ("Arc", trace::), + ("Ellipse", trace::), + ("Polyline", trace::), + ("Polyline2D", trace::), + ("Polyline3D", trace::), + ("LwPolyline", trace::), + ("Spline", trace::), + ("EntityCommon", trace::), + ("Handle", trace::), + ("Vector2", trace::), + ("Vector3", trace::), + ("Color", trace::), + ("Layer", trace::), + ("XDataValue", trace::), + ]; + + for (name, f) in types { + f(&mut tracer, &samples); + // serde-reflection accumulates named types as it traces; we only need + // to ensure the seed types are recorded even if a nested trace fails. + eprintln!("[ocs_plugin_api build] traced type registry entry: {}", name); + } + + let traced = tracer + .registry() + .expect("type registry tracing failed; see stderr for individual errors"); + let registry = map_to_custom_schema(&traced); + let json = serde_json::to_string_pretty(®istry).unwrap(); + fs::write(out_dir.join("type_registry.json"), json).unwrap(); +} + +fn trace(tracer: &mut Tracer, samples: &Samples) +where + T: serde::Serialize + DeserializeOwned, +{ + let name = std::any::type_name::(); + if let Err(e) = tracer.trace_type::(samples) { + eprintln!("[ocs_plugin_api build] warning: tracing {} failed: {}", name, e); + } +} + +fn add_enum_samples(tracer: &mut Tracer, samples: &mut Samples) { + // serde-reflection needs at least one sample value per enum variant in + // order to reconstruct the full schema. Provide samples for the enums that + // appear inside the allow-list types below. + let _ = tracer.trace_value(samples, &acadrust::LineWeight::ByLayer); + let _ = tracer.trace_value(samples, &acadrust::LineWeight::ByBlock); + let _ = tracer.trace_value(samples, &acadrust::LineWeight::Default); + let _ = tracer.trace_value(samples, &acadrust::LineWeight::Value(0)); + + let _ = tracer.trace_value(samples, &acadrust::entities::SmoothSurfaceType::None); + let _ = tracer.trace_value(samples, &acadrust::entities::SmoothSurfaceType::QuadraticBSpline); + let _ = tracer.trace_value(samples, &acadrust::entities::SmoothSurfaceType::CubicBSpline); + let _ = tracer.trace_value(samples, &acadrust::entities::SmoothSurfaceType::Bezier); + + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::String(String::new())); + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::ControlString(String::new())); + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::LayerName(String::new())); + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::BinaryData(Vec::new())); + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Handle(acadrust::Handle::default())); + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Point3D(acadrust::Vector3::default())); + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Position3D(acadrust::Vector3::default())); + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Displacement3D(acadrust::Vector3::default())); + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Direction3D(acadrust::Vector3::default())); + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Real(0.0)); + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Distance(0.0)); + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::ScaleFactor(0.0)); + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Integer16(0)); + let _ = tracer.trace_value(samples, &acadrust::xdata::XDataValue::Integer32(0)); +} + +fn map_to_custom_schema(traced: &Registry) -> TypeRegistry { + let mut types = BTreeMap::new(); + for (name, format) in traced.iter() { + let info = map_container(name, format); + types.insert(TypeId(name.clone()), info); + } + TypeRegistry { types } +} + +fn map_container(name: &str, format: &ContainerFormat) -> TypeInfo { + match format { + ContainerFormat::Struct(fields) => TypeInfo { + name: TypeId(name.to_string()), + kind: TypeKind::Struct, + fields: fields.iter().map(map_field).collect(), + variants: vec![], + methods: vec![], + doc: None, + }, + ContainerFormat::Enum(variants) => TypeInfo { + name: TypeId(name.to_string()), + kind: TypeKind::Enum, + fields: vec![], + variants: variants + .iter() + .map(|(idx, v)| map_variant(*idx, v)) + .collect(), + methods: vec![], + doc: None, + }, + ContainerFormat::NewTypeStruct(format) => TypeInfo { + name: TypeId(name.to_string()), + kind: TypeKind::Newtype, + fields: vec![FieldInfo { + name: "0".to_string(), + ..map_format_field(format) + }], + variants: vec![], + methods: vec![], + doc: None, + }, + ContainerFormat::TupleStruct(formats) => TypeInfo { + name: TypeId(name.to_string()), + kind: TypeKind::Tuple, + fields: formats + .iter() + .enumerate() + .map(|(i, f)| FieldInfo { + name: i.to_string(), + ..map_format_field(f) + }) + .collect(), + variants: vec![], + methods: vec![], + doc: None, + }, + ContainerFormat::UnitStruct => TypeInfo { + name: TypeId(name.to_string()), + kind: TypeKind::Unit, + fields: vec![], + variants: vec![], + methods: vec![], + doc: None, + }, + } +} + +fn map_field(named: &Named) -> FieldInfo { + let mut field = map_format_field(&named.value); + field.name = named.name.clone(); + field +} + +fn map_format_field(format: &Format) -> FieldInfo { + if let Some(name) = primitive_format_name(format) { + return FieldInfo { + name: String::new(), + type_id: TypeId(name), + optional: false, + is_sequence: false, + }; + } + match format { + Format::TypeName(name) => FieldInfo { + name: String::new(), + type_id: TypeId(name.clone()), + optional: false, + is_sequence: false, + }, + Format::Option(inner) => { + let mut field = map_format_field(inner); + field.optional = true; + field + } + Format::Seq(inner) => { + let mut field = map_format_field(inner); + field.is_sequence = true; + field + } + Format::TupleArray { content, size } => { + let mut field = map_format_field(content); + field.type_id = TypeId(format!("[{}; {}]", type_id_of_format(content), size)); + field.is_sequence = true; + field + } + Format::Map { key, value } => FieldInfo { + name: String::new(), + type_id: TypeId(format!( + "Map<{}, {}>", + type_id_of_format(key), + type_id_of_format(value) + )), + optional: false, + is_sequence: false, + }, + Format::Tuple(formats) => FieldInfo { + name: String::new(), + type_id: TypeId(format!( + "({})", + formats + .iter() + .map(type_id_of_format) + .collect::>() + .join(",") + )), + optional: false, + is_sequence: false, + }, + Format::Variable(_) => FieldInfo { + name: String::new(), + type_id: TypeId("Value".to_string()), + optional: false, + is_sequence: false, + }, + _ => FieldInfo { + name: String::new(), + type_id: TypeId("unknown".to_string()), + optional: false, + is_sequence: false, + }, + } +} + +fn primitive_format_name(format: &Format) -> Option { + let name = match format { + Format::Unit => "()", + Format::Bool => "bool", + Format::I8 => "i8", + Format::I16 => "i16", + Format::I32 => "i32", + Format::I64 => "i64", + Format::I128 => "i128", + Format::U8 => "u8", + Format::U16 => "u16", + Format::U32 => "u32", + Format::U64 => "u64", + Format::U128 => "u128", + Format::F32 => "f32", + Format::F64 => "f64", + Format::Char => "char", + Format::Str => "String", + Format::Bytes => "bytes", + _ => return None, + }; + Some(name.to_string()) +} + +fn type_id_of_format(format: &Format) -> String { + if let Some(name) = primitive_format_name(format) { + return name; + } + match format { + Format::TypeName(name) => name.clone(), + Format::Option(inner) => format!("Option<{}>", type_id_of_format(inner)), + Format::Seq(inner) => format!("Vec<{}>", type_id_of_format(inner)), + Format::TupleArray { content, size } => { + format!("[{}; {}]", type_id_of_format(content), size) + } + Format::Map { key, value } => format!( + "Map<{}, {}>", + type_id_of_format(key), + type_id_of_format(value) + ), + Format::Tuple(formats) => format!( + "({})", + formats + .iter() + .map(type_id_of_format) + .collect::>() + .join(",") + ), + Format::Variable(_) => "Value".to_string(), + _ => "unknown".to_string(), + } +} + +fn map_variant(discriminant: u32, named: &Named) -> EnumVariantInfo { + let fields = match &named.value { + VariantFormat::Unit => vec![], + VariantFormat::Variable(_) => vec![], + VariantFormat::NewType(format) => vec![FieldInfo { + name: "0".to_string(), + ..map_format_field(format) + }], + VariantFormat::Tuple(formats) => formats + .iter() + .enumerate() + .map(|(i, f)| FieldInfo { + name: i.to_string(), + ..map_format_field(f) + }) + .collect(), + VariantFormat::Struct(fields) => fields.iter().map(map_field).collect(), + }; + EnumVariantInfo { + name: named.name.clone(), + discriminant, + fields, + } +} + +// ════════════════════════════════════════════════════════════════════════════ +// Version info +// ════════════════════════════════════════════════════════════════════════════ + +fn generate_version_info(out_dir: &Path) { + let lock_path = workspace_cargo_lock_path(); + let lockfile = Lockfile::load(&lock_path).expect("load Cargo.lock"); + + // Use Cargo.lock's mtime as the build timestamp so the embedded JSON stays + // stable across normal incremental builds and only changes when dependencies + // are updated. Stored as Unix seconds to avoid an extra date-formatting + // dependency in the build script. + let build_timestamp = fs::metadata(&lock_path) + .and_then(|m| m.modified()) + .and_then(|t| { + t.duration_since(SystemTime::UNIX_EPOCH) + .map(|d| d.as_secs()) + .map_err(std::io::Error::other) + }) + .unwrap_or(0); + + let ocs = lockfile + .packages + .iter() + .find(|p| p.name.as_str() == "OpenCADStudio") + .expect("OpenCADStudio package in Cargo.lock"); + let acadrust = lockfile + .packages + .iter() + .find(|p| p.name.as_str() == "acadrust") + .expect("acadrust package in Cargo.lock"); + + let info = serde_json::json!({ + "ocs_version": ocs.version.to_string(), + "ocs_plugin_api_version": env!("CARGO_PKG_VERSION"), + "acadrust_version": acadrust.version.to_string(), + "acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()), + "api_version": 4, + "api_version_min_supported": 2, + "build_timestamp": build_timestamp, + }); + fs::write(out_dir.join("version_info.json"), info.to_string()).unwrap(); +} + +fn workspace_cargo_lock_path() -> PathBuf { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + // CARGO_MANIFEST_DIR is crates/ocs_plugin_api; walk up to the workspace root. + manifest_dir + .parent() + .and_then(|p| p.parent()) + .map(|p| p.join("Cargo.lock")) + .expect("Cargo.lock in workspace root") +} diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index 5371fb6d6..66582f102 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -13,12 +13,253 @@ use std::any::Any; -use acadrust::xdata::ExtendedDataRecord; -use acadrust::{CadDocument, EntityType, Handle}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; use crate::manifest::PluginManifest; use crate::ribbon::CadModule; +// Re-export the acadrust crate and the types that appear in the HostApi trait +// so out-of-tree plugins can use them without adding their own acadrust +// dependency (which would risk an ABI-mismatching version). +pub use acadrust; +pub use acadrust::{CadDocument, EntityType, Handle}; +pub use acadrust::xdata::ExtendedDataRecord; + +use crate::ipc::protocol::{PluginRequest, PluginResponse}; + +/// Thread-safe handle that can issue host requests from plugin worker threads. +/// Out-of-process V4 plugins implement this; in-process hosts may return `None`. +pub trait PluginRequestSender: Send + Sync { + fn request(&self, req: PluginRequest) -> Result; +} + +/// Error returned when a plugin worker thread cannot issue a host request. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PluginRequestError(pub String); + +impl std::fmt::Display for PluginRequestError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "PluginRequestError: {}", self.0) + } +} + +impl std::error::Error for PluginRequestError {} + +/// Log level carried by [`PluginNotification::Log`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[non_exhaustive] +pub enum LogLevel { + Debug, + Info, + Warn, + Error, +} + +/// A notification the host sends to a plugin. These are best-effort, +/// full-duplex messages correlated with an optional `command_id`. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum HostNotification { + InputLine { line: String }, + Cancel, + DocumentChanged { version: u64 }, + SelectionChanged { handles: Vec }, + Raw(Vec), + /// V4 snapshot changed for a specific tab. Discriminant 5. + DocumentChangedV4 { tab_id: u64, version: u64 }, + /// V4 tab closed notification. Discriminant 6. + DocumentTabClosed { tab_id: u64 }, + /// Fallback for notification variants added in future minor revisions. + /// Carries the raw bincode payload so an older peer can ignore it without + /// failing deserialization. + Unknown(Vec), +} + +impl Serialize for HostNotification { + fn serialize(&self, serializer: S) -> Result { + let mut bytes = Vec::new(); + match self { + HostNotification::InputLine { line } => { + bytes.push(0); + bincode::serialize_into(&mut bytes, line) + .map_err(serde::ser::Error::custom)?; + } + HostNotification::Cancel => bytes.push(1), + HostNotification::DocumentChanged { version } => { + bytes.push(2); + bincode::serialize_into(&mut bytes, version) + .map_err(serde::ser::Error::custom)?; + } + HostNotification::SelectionChanged { handles } => { + bytes.push(3); + bincode::serialize_into(&mut bytes, handles) + .map_err(serde::ser::Error::custom)?; + } + HostNotification::Raw(data) => { + bytes.push(4); + bincode::serialize_into(&mut bytes, data) + .map_err(serde::ser::Error::custom)?; + } + HostNotification::DocumentChangedV4 { tab_id, version } => { + bytes.push(5); + bincode::serialize_into(&mut bytes, tab_id) + .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, version) + .map_err(serde::ser::Error::custom)?; + } + HostNotification::DocumentTabClosed { tab_id } => { + bytes.push(6); + bincode::serialize_into(&mut bytes, tab_id) + .map_err(serde::ser::Error::custom)?; + } + HostNotification::Unknown(raw) => bytes.extend_from_slice(raw), + } + bytes.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for HostNotification { + fn deserialize>(deserializer: D) -> Result { + let bytes = Vec::::deserialize(deserializer)?; + if bytes.is_empty() { + return Err(serde::de::Error::custom("empty HostNotification")); + } + let discriminant = bytes[0]; + let rest = &bytes[1..]; + match discriminant { + 0 => bincode::deserialize(rest) + .map(|line| HostNotification::InputLine { line }) + .map_err(serde::de::Error::custom), + 1 => Ok(HostNotification::Cancel), + 2 => bincode::deserialize(rest) + .map(|version| HostNotification::DocumentChanged { version }) + .map_err(serde::de::Error::custom), + 3 => bincode::deserialize(rest) + .map(|handles| HostNotification::SelectionChanged { handles }) + .map_err(serde::de::Error::custom), + 4 => bincode::deserialize(rest) + .map(HostNotification::Raw) + .map_err(serde::de::Error::custom), + 5 => bincode::deserialize(rest) + .map(|(tab_id, version)| HostNotification::DocumentChangedV4 { tab_id, version }) + .map_err(serde::de::Error::custom), + 6 => bincode::deserialize(rest) + .map(|tab_id| HostNotification::DocumentTabClosed { tab_id }) + .map_err(serde::de::Error::custom), + _ => Ok(HostNotification::Unknown(bytes)), + } + } +} + +/// A notification a plugin sends to the host. These are best-effort, +/// full-duplex messages correlated with an optional `command_id`. +#[derive(Debug, Clone, PartialEq)] +#[non_exhaustive] +pub enum PluginNotification { + Output { text: String }, + Error { text: String }, + Prompt { text: String }, + Progress { percent: u8 }, + Log { level: LogLevel, text: String }, + Raw(Vec), + /// V4 REPL status update. Discriminant 6. + ReplStatus { status: String, message: String }, + /// Fallback for notification variants added in future minor revisions. + /// Carries the raw bincode payload so an older peer can ignore it without + /// failing deserialization. + Unknown(Vec), +} + +impl Serialize for PluginNotification { + fn serialize(&self, serializer: S) -> Result { + let mut bytes = Vec::new(); + match self { + PluginNotification::Output { text } => { + bytes.push(0); + bincode::serialize_into(&mut bytes, text) + .map_err(serde::ser::Error::custom)?; + } + PluginNotification::Error { text } => { + bytes.push(1); + bincode::serialize_into(&mut bytes, text) + .map_err(serde::ser::Error::custom)?; + } + PluginNotification::Prompt { text } => { + bytes.push(2); + bincode::serialize_into(&mut bytes, text) + .map_err(serde::ser::Error::custom)?; + } + PluginNotification::Progress { percent } => { + bytes.push(3); + bincode::serialize_into(&mut bytes, percent) + .map_err(serde::ser::Error::custom)?; + } + PluginNotification::Log { level, text } => { + bytes.push(4); + bincode::serialize_into(&mut bytes, level) + .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, text) + .map_err(serde::ser::Error::custom)?; + } + PluginNotification::Raw(data) => { + bytes.push(5); + bincode::serialize_into(&mut bytes, data) + .map_err(serde::ser::Error::custom)?; + } + PluginNotification::ReplStatus { status, message } => { + bytes.push(6); + bincode::serialize_into(&mut bytes, status) + .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, message) + .map_err(serde::ser::Error::custom)?; + } + PluginNotification::Unknown(raw) => bytes.extend_from_slice(raw), + } + bytes.serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for PluginNotification { + fn deserialize>(deserializer: D) -> Result { + let bytes = Vec::::deserialize(deserializer)?; + if bytes.is_empty() { + return Err(serde::de::Error::custom("empty PluginNotification")); + } + let discriminant = bytes[0]; + let rest = &bytes[1..]; + match discriminant { + 0 => bincode::deserialize(rest) + .map(|text| PluginNotification::Output { text }) + .map_err(serde::de::Error::custom), + 1 => bincode::deserialize(rest) + .map(|text| PluginNotification::Error { text }) + .map_err(serde::de::Error::custom), + 2 => bincode::deserialize(rest) + .map(|text| PluginNotification::Prompt { text }) + .map_err(serde::de::Error::custom), + 3 => bincode::deserialize(rest) + .map(|percent| PluginNotification::Progress { percent }) + .map_err(serde::de::Error::custom), + 4 => { + let (level, text): (LogLevel, String) = bincode::deserialize(rest) + .map_err(serde::de::Error::custom)?; + Ok(PluginNotification::Log { level, text }) + } + 5 => bincode::deserialize(rest) + .map(PluginNotification::Raw) + .map_err(serde::de::Error::custom), + 6 => bincode::deserialize(rest) + .map(|(status, message)| PluginNotification::ReplStatus { status, message }) + .map_err(serde::de::Error::custom), + _ => Ok(PluginNotification::Unknown(bytes)), + } + } +} + +/// An add-on package's entry point: its manifest, optional ribbon tab, and +/// command dispatch. Built-in (in-tree) and dynamically-loaded (cdylib) plugins +/// implement the same trait from this crate, so an out-of-tree add-on targets +/// the stable contract rather than the host binary. /// An add-on package's entry point: its manifest, optional ribbon tab, and /// command dispatch. Built-in (in-tree) and dynamically-loaded (cdylib) plugins /// implement the same trait from this crate, so an out-of-tree add-on targets @@ -27,6 +268,28 @@ pub trait BuiltinPlugin: Send + Sync { fn manifest(&self) -> &'static PluginManifest; fn ribbon(&self) -> Box; fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool; + + /// Long-running REPL code execution (API v4). The plugin may start the + /// work on another thread and call `respond` when finished; the runner + /// will forward the result to the host. The supplied `host` is the active + /// document tab's API surface, so the REPL session is tied to a tab. + /// + /// Returning `false` means the plugin does not support code execution. + fn start_execute_code( + &mut self, + _host: &mut dyn HostApi, + _command_id: u64, + _code: &str, + _source: CommandSource, + _respond: Box, + ) -> bool { + false + } + + /// State-only callback for host-to-plugin notifications. Added in API v4; + /// the V4 runner guarantees it is only called for plugins that report + /// API major 4 or newer. + fn on_notification(&mut self, _command_id: Option, _notification: HostNotification) {} } /// A point-driven interactive command a plugin starts via @@ -132,6 +395,12 @@ pub trait HostApi { /// Add an entity to the active document, returning its handle. fn add_entity(&mut self, entity: EntityType) -> Handle; + /// Add multiple entities to the active document, returning their handles. + /// The default implementation calls [`add_entity`](Self::add_entity) for each + /// entity; hosts should override it for batch efficiency. + fn add_entities(&mut self, entities: Vec) -> Vec { + entities.into_iter().map(|e| self.add_entity(e)).collect() + } /// Replace the existing entity that carries `entity`'s handle, preserving /// its identity (handle and owning block). Returns `false` when no entity /// has that handle. This is the sanctioned way to commit in-place edits @@ -203,6 +472,53 @@ pub trait HostApi { fn document_view(&mut self) -> Option { None } + + // ── Notifications (added in API v4; appended at the end to keep vtable + // indices stable for V2/V3 plugins) ─────────────────────────────────────── + + /// Send a best-effort notification from the plugin to the host. + /// + /// In-process plugins can override this to forward to the host event loop; + /// out-of-process plugins send a V4 notification frame. + fn notify_plugin(&mut self, _command_id: Option, _notification: PluginNotification) {} + + /// Poll for a host-to-plugin notification, if any. + /// + /// Returns the optional `command_id` used to correlate the notification + /// with a running command, and the notification payload. Long-running + /// plugins should call this periodically to drain the bounded queue. + fn try_recv_notification( + &mut self, + ) -> Option<(Option, HostNotification)> { + None + } + + /// Returns a thread-safe handle that can issue host requests from worker + /// threads. Out-of-process V4 plugins implement this; in-process hosts may + /// return `None`. + fn plugin_request_sender(&self) -> Option> { + None + } + + // ── V4 tab/document identity (added for REPL; appended at the end) ─────── + + /// Stable tab identifier for the active document tab. + fn tab_id(&self) -> u64 { + self.tab_index() as u64 + } + + /// Open (or refresh) the host-side V4 shared document view for `tab_id` + /// and return the mapping information. In-process hosts implement this; + /// out-of-process plugin proxies return `None`. + fn document_view_v4(&mut self, tab_id: u64) -> Option { + let _ = tab_id; + None + } + + /// Close the host-side V4 shared document view for `tab_id`. + fn close_document_view_v4(&mut self, tab_id: u64) { + let _ = tab_id; + } } /// Simplified, read-only entity kind exposed by [`DocumentReader`]. @@ -349,3 +665,95 @@ pub fn ensure_plugin_state<'a, T: Any + Send + Sync>( any.downcast_mut::() .expect("plugin state type mismatch for plugin_id") } + +#[cfg(feature = "host")] +mod repl { + use serde::{Deserialize, Serialize}; + + /// Source surface that submitted a REPL code snippet. + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] + #[non_exhaustive] + pub enum CommandSource { + CommandLine, + Script, + Editor, + } + + /// Outcome of a REPL `ExecuteCode` request. + #[derive(Debug, Clone, Serialize, Deserialize)] + #[non_exhaustive] + pub struct ExecutionResult { + pub success: bool, + pub output: Option, + pub error: Option, + pub error_type: Option, + pub traceback: Option, + pub line_number: Option, + pub column_number: Option, + pub duration_ms: f64, + } + + impl ExecutionResult { + /// Create a new execution result. + pub fn new( + success: bool, + output: Option, + error: Option, + error_type: Option, + traceback: Option, + line_number: Option, + column_number: Option, + duration_ms: f64, + ) -> Self { + Self { + success, + output, + error, + error_type, + traceback, + line_number, + column_number, + duration_ms, + } + } + } + + /// Spawn a thread that runs `f` and calls `respond` with the returned + /// `ExecutionResult`. If `f` panics, `respond` is called with a panic error + /// result instead, so the host never waits indefinitely for a callback that + /// will never arrive. + pub fn execute_code_guard( + respond: Box, + f: F, + ) -> std::thread::JoinHandle<()> + where + F: FnOnce() -> ExecutionResult + Send + 'static, + { + std::thread::spawn(move || { + let result = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)) { + Ok(result) => result, + Err(payload) => { + let msg = payload + .downcast_ref::<&str>() + .copied() + .or_else(|| payload.downcast_ref::().map(|s| s.as_str())) + .unwrap_or("execute_code panicked"); + ExecutionResult { + success: false, + output: None, + error: Some(format!("panic: {}", msg)), + error_type: Some("panic".to_string()), + traceback: None, + line_number: None, + column_number: None, + duration_ms: 0.0, + } + } + }; + respond(result); + }) + } +} + +#[cfg(feature = "host")] +pub use repl::{execute_code_guard, CommandSource, ExecutionResult}; diff --git a/crates/ocs_plugin_api/src/host_v4.rs b/crates/ocs_plugin_api/src/host_v4.rs new file mode 100644 index 000000000..c6341338f --- /dev/null +++ b/crates/ocs_plugin_api/src/host_v4.rs @@ -0,0 +1,91 @@ +//! Host-side V4 snapshot manager. +//! +//! Maintains a per-tab shared-memory document view keyed by the stable +//! `DocumentTab.id`. The manager is used by `src/plugin/v4_support.rs` to +//! open, publish, and close V4 snapshots independently of the V3 reader path. + +use std::collections::HashMap; +use std::sync::{Mutex, OnceLock}; + +use acadrust::CadDocument; + +use crate::shm::{DocumentSnapshotStore, DocumentViewDataV4, DocumentViewInfo}; + +static MANAGER: OnceLock> = OnceLock::new(); + +fn global_manager() -> &'static Mutex { + MANAGER.get_or_init(|| Mutex::new(HostV4SnapshotManager::new())) +} + +/// Per-tab V4 shared-memory snapshot manager. +pub struct HostV4SnapshotManager { + stores: HashMap>, + segment_size: usize, +} + +impl HostV4SnapshotManager { + /// Create a new manager with the default segment size. + pub fn new() -> Self { + Self { + stores: HashMap::new(), + segment_size: default_segment_size(), + } + } + + /// Open (or refresh) the V4 snapshot for `tab_id` and return the view info. + pub fn open(&mut self, tab_id: u64, doc: &CadDocument) -> Option { + let store = match self.stores.get_mut(&tab_id) { + Some(store) => store, + None => { + let store = DocumentSnapshotStore::new(tab_id, self.segment_size).ok()?; + self.stores.entry(tab_id).or_insert(store) + } + }; + if let Err(e) = store.publish(&doc.into()) { + eprintln!("[host] failed to publish V4 document view for tab {tab_id}: {e}"); + return None; + } + Some(DocumentViewInfo { + path: store.path().to_string_lossy().into_owned(), + version: store.version(), + }) + } + + /// Publish the latest document to the existing V4 snapshot for `tab_id`. + /// Returns the updated view info if the snapshot exists. + pub fn publish(&mut self, tab_id: u64, doc: &CadDocument) -> Option { + let store = self.stores.get_mut(&tab_id)?; + if let Err(e) = store.publish(&doc.into()) { + eprintln!("[host] failed to publish V4 document view for tab {tab_id}: {e}"); + return None; + } + Some(DocumentViewInfo { + path: store.path().to_string_lossy().into_owned(), + version: store.version(), + }) + } + + /// Close the V4 snapshot for `tab_id`. + pub fn close(&mut self, tab_id: u64) { + self.stores.remove(&tab_id); + } +} + +impl Default for HostV4SnapshotManager { + fn default() -> Self { + Self::new() + } +} + +/// Global accessor for the host V4 snapshot manager. +pub fn manager() -> &'static Mutex { + global_manager() +} + +fn default_segment_size() -> usize { + std::env::var("OCS_V4_SNAPSHOT_SEGMENT_SIZE") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(16 * 1024 * 1024) + .max(1024 * 1024) +} diff --git a/crates/ocs_plugin_api/src/ipc/client.rs b/crates/ocs_plugin_api/src/ipc/client.rs index f3c6c1c32..4fb050e1a 100644 --- a/crates/ocs_plugin_api/src/ipc/client.rs +++ b/crates/ocs_plugin_api/src/ipc/client.rs @@ -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>>>; @@ -60,10 +60,10 @@ impl IpcClient { &self, req: PluginRequest, ) -> Result { - send(&mut self.stream.borrow_mut(), &PluginToHost::Request(req))?; + send(&mut self.stream.borrow_mut(), &PluginToHost::Request(Box::new(req)))?; loop { match recv::(&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) -> Vec { + 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::::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::(&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::(&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::(&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::(&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::(&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(); diff --git a/crates/ocs_plugin_api/src/ipc/mod.rs b/crates/ocs_plugin_api/src/ipc/mod.rs index 08cb9c212..5190eb1a4 100644 --- a/crates/ocs_plugin_api/src/ipc/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/mod.rs @@ -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::(&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::(&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:?}"), + } + } } diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index e3ef24101..9d3c3f917 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -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), Text(String), Ribbon(Vec), 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), /// 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), Record(Option), - Document(CadDocument), + Document(Box), 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), } /// Messages sent from the plugin runner to the host. #[derive(Debug, Serialize, Deserialize)] pub enum PluginToHost { - Request(PluginRequest), + Request(Box), Response(HostResponse), } diff --git a/crates/ocs_plugin_api/src/ipc/proxy.rs b/crates/ocs_plugin_api/src/ipc/proxy.rs new file mode 100644 index 000000000..f40ec43d3 --- /dev/null +++ b/crates/ocs_plugin_api/src/ipc/proxy.rs @@ -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 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(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(reader: &mut R) -> Result { + 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, +} + +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 { + 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 { + 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 { + 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 { + 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, + 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, + 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::().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 { + 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 = 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)); + } +} diff --git a/crates/ocs_plugin_api/src/ipc/server.rs b/crates/ocs_plugin_api/src/ipc/server.rs index 595d64899..51ad9ac8a 100644 --- a/crates/ocs_plugin_api/src/ipc/server.rs +++ b/crates/ocs_plugin_api/src/ipc/server.rs @@ -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()), } } diff --git a/crates/ocs_plugin_api/src/ipc/transport.rs b/crates/ocs_plugin_api/src/ipc/transport.rs index b2009f0c4..69e42ce6f 100644 --- a/crates/ocs_plugin_api/src/ipc/transport.rs +++ b/crates/ocs_plugin_api/src/ipc/transport.rs @@ -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(stream: &mut Stream, msg: &T) -> Result<(), TransportE /// Receive a length-framed serialized message. pub fn recv(stream: &mut Stream) -> Result { 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(stream: &mut Stream) -> Result, HostNotification)>; + +/// Shared state between the plugin main thread, the reader thread, and any +/// cloned `V4PluginHostApi` proxies. +struct Shared { + writer: Mutex, + next_id: AtomicU64, + in_flight: Mutex>>, +} + +/// 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, + notifications: Arc>, + runner_queue: mpsc::Receiver, +} + +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 { + let name = name + .to_ns_name::() + .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 { + self.runner_queue.recv() + } + + /// Non-blocking poll for host-to-plugin notifications. + pub fn try_recv_notification(&self) -> Option<(Option, 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, + 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 { + 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>>>, + ) -> 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, + notify_tx: mpsc::SyncSender<(Option, HostNotification)>, + runner_tx: mpsc::Sender, +) { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut last_drop_log: Option = None; + loop { + match recv::(&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, + notifications: Arc>, + tab_index: usize, + document_cache: OnceCell, + interactive: Rc>>>, + next_command_id: Cell, + record_cache: RefCell>, + doc_view: RefCell>, + doc_view_v4: RefCell>, + tab_id_cache: Cell>, +} + +impl V4PluginHostApi { + fn new( + shared: Arc, + notifications: Arc>, + tab_index: usize, + interactive: Rc>>>, + ) -> 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 { + 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, + req: PluginRequest, +) -> Result { + 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, + lock: Mutex<()>, +} + +impl PluginRequestSender for V4PluginRequestSender { + fn request(&self, req: PluginRequest) -> Result { + 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) -> Vec { + 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) { + 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, + ) -> &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 { + { + 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::::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, 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, HostNotification)> { + self.notifications.lock().unwrap_or_else(|e| e.into_inner()).try_recv().ok() + } + + fn plugin_request_sender(&self) -> Option> { + 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 { + { + 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::() + .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::().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::(&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::(&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::(&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:?}"), + } + } +} diff --git a/crates/ocs_plugin_api/src/ipc/v4/mod.rs b/crates/ocs_plugin_api/src/ipc/v4/mod.rs new file mode 100644 index 000000000..e67788bde --- /dev/null +++ b/crates/ocs_plugin_api/src/ipc/v4/mod.rs @@ -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; diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs new file mode 100644 index 000000000..cf1d32cfd --- /dev/null +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -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 { + pub command_id: Option, + 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), +} + +/// 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), +} + +#[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:?}"), + } + } +} diff --git a/crates/ocs_plugin_api/src/ipc/v4/server.rs b/crates/ocs_plugin_api/src/ipc/v4/server.rs new file mode 100644 index 000000000..f3c7a26ed --- /dev/null +++ b/crates/ocs_plugin_api/src/ipc/v4/server.rs @@ -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 }, +} + +/// 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>, + 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, + incoming: mpsc::Sender, + handler: Arc, PluginNotification) + Send + Sync>, + mut rate_limiter: RateLimiter, +) { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + let mut last_drop_log: Option = None; + loop { + match recv::(&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()); + } +} diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index 3d3942099..e721983e0 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -13,16 +13,28 @@ //! ([`RibbonGroup`], [`ToolDef`], …) a plugin uses to describe its tab. //! //! The runtime host surface a plugin uses at *dispatch* time (document access, -//! command line, undo) is `acadrust`-typed and therefore lives in the host -//! binary for now; see `docs/plugin-architecture.md` (phase 1b). +//! command line, undo) is `acadrust`-typed and therefore lives in the `host` +//! feature; it re-exports `acadrust` so plugins do not need to depend on it +//! directly and risk an ABI mismatch from a different version. +//! +//! For binary compatibility, the host and every plugin must resolve the same +//! `acadrust` source. The host does this via a `[patch.crates-io]` entry in +//! `Cargo.toml`; out-of-tree plugins should copy that exact patch. pub mod manifest; pub mod ribbon; +pub mod type_registry; +pub mod type_registry_types; +pub mod version_info; /// Runtime host surface — only built with the `host` feature (pulls `acadrust`). #[cfg(feature = "host")] pub mod host; +/// Host-side V4 snapshot manager — only built with the `host` feature. +#[cfg(feature = "host")] +pub mod host_v4; + /// Out-of-process plugin runtime — only built with the `host` feature. #[cfg(feature = "host")] pub mod ipc; @@ -42,9 +54,15 @@ pub mod shm; pub mod runner; pub use manifest::{ - host_accepts_plugin_version, ApiVersion, PluginManifest, API_VERSION, API_VERSION_MIN_SUPPORTED, + effective_max_api_version, host_accepts_plugin_version, ApiVersion, PluginManifest, + API_VERSION, API_VERSION_MIN_SUPPORTED, MAX_API_VERSION_ENV, }; pub use ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, StyleKey, ToolDef}; +pub use type_registry::{ + get_embedded_type_registry_json, EnumVariantInfo, FieldInfo, MethodInfo, ParameterInfo, + TypeId, TypeInfo, TypeKind, TypeRegistry, +}; +pub use version_info::get_embedded_version_info_json; #[cfg(feature = "host")] pub use process::{DispatchResult, PluginError, PluginManager, PluginProcess}; diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index efcc04037..0d4593411 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -4,14 +4,21 @@ /// compatibility. v2 added `HostApi::start_interactive`. v3 changes /// `document()` / `document_mut()` to local cached copies for out-of-process /// plugins and appends `document_reader` / `document_view` at the end of the -/// vtable so API v2 plugins keep working. -pub const API_VERSION: u32 = 3; +/// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on +/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. +pub const API_VERSION: u32 = 4; /// Oldest plugin API major the current host still loads. This keeps previously /// compiled cdylibs usable as long as their vtable layout is a prefix of the /// current `HostApi` trait. pub const API_VERSION_MIN_SUPPORTED: u32 = 2; +/// Environment variable that caps the API major accepted by the host at +/// runtime. Set to `3` to disable V4 plugins while keeping V2/V3 plugins +/// working; set to `2` for V2-only mode. The same mechanism will apply to +/// future API majors. +pub const MAX_API_VERSION_ENV: &str = "OCS_PLUGIN_MAX_API_VERSION"; + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ApiVersion { pub major: u32, @@ -28,17 +35,33 @@ impl ApiVersion { } } +/// The effective maximum API major this host accepts, honoring +/// [`MAX_API_VERSION_ENV`]. This lets operators disable a new API major at +/// runtime without rebuilding. +pub fn effective_max_api_version() -> u32 { + std::env::var(MAX_API_VERSION_ENV) + .ok() + .and_then(|s| s.parse().ok()) + .map(|max: u32| max.clamp(API_VERSION_MIN_SUPPORTED, API_VERSION)) + .unwrap_or(API_VERSION) +} + /// True when a plugin built against `plugin_major` can be loaded by this host. /// The host supports majors from `API_VERSION_MIN_SUPPORTED` up to -/// `API_VERSION`. +/// [`effective_max_api_version`], which can be lowered at runtime via +/// `OCS_PLUGIN_MAX_API_VERSION`. pub fn host_accepts_plugin_version(plugin_major: u32) -> bool { - plugin_major >= API_VERSION_MIN_SUPPORTED && plugin_major <= API_VERSION + plugin_major >= API_VERSION_MIN_SUPPORTED && plugin_major <= effective_max_api_version() } #[cfg(test)] mod tests { + use std::sync::Mutex; + use super::*; + static ENV_LOCK: Mutex<()> = Mutex::new(()); + #[test] fn current_matches_const() { assert_eq!(ApiVersion::CURRENT.major, API_VERSION); @@ -61,6 +84,55 @@ mod tests { fn api_v2_plugin_runs_on_api_v3_host() { assert!(ApiVersion { major: 2 }.is_compatible_with(ApiVersion { major: 3 })); } + + #[test] + fn effective_max_defaults_to_api_version() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::remove_var(MAX_API_VERSION_ENV); + assert_eq!(effective_max_api_version(), API_VERSION); + } + + #[test] + fn effective_max_can_disable_v4() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var(MAX_API_VERSION_ENV, "3"); + assert_eq!(effective_max_api_version(), 3); + assert!(!host_accepts_plugin_version(4)); + assert!(host_accepts_plugin_version(3)); + assert!(host_accepts_plugin_version(2)); + std::env::remove_var(MAX_API_VERSION_ENV); + } + + #[test] + fn effective_max_v2_only_mode() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var(MAX_API_VERSION_ENV, "2"); + assert_eq!(effective_max_api_version(), 2); + assert!(!host_accepts_plugin_version(4)); + assert!(!host_accepts_plugin_version(3)); + assert!(host_accepts_plugin_version(2)); + std::env::remove_var(MAX_API_VERSION_ENV); + } + + #[test] + fn effective_max_clamps_to_supported_range() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var(MAX_API_VERSION_ENV, "99"); + assert_eq!(effective_max_api_version(), API_VERSION); + std::env::set_var(MAX_API_VERSION_ENV, "1"); + assert_eq!(effective_max_api_version(), API_VERSION_MIN_SUPPORTED); + std::env::remove_var(MAX_API_VERSION_ENV); + } + + #[test] + fn host_accepts_supported_range() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::remove_var(MAX_API_VERSION_ENV); + assert!(host_accepts_plugin_version(API_VERSION_MIN_SUPPORTED)); + assert!(host_accepts_plugin_version(API_VERSION)); + assert!(!host_accepts_plugin_version(API_VERSION + 1)); + assert!(!host_accepts_plugin_version(API_VERSION_MIN_SUPPORTED - 1)); + } } /// Static metadata every plugin supplies at registration time. diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 90d9ad684..6b5170c21 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -10,19 +10,97 @@ use std::time::{Duration, Instant}; use interprocess::local_socket::traits::Listener; use interprocess::local_socket::{GenericNamespaced, ListenerOptions, Stream, ToNsName}; -use crate::host::{CommandStep, HostApi}; +use crate::host::{CommandSource, CommandStep, ExecutionResult, HostApi, HostNotification, PluginNotification}; use crate::ipc::protocol::{ - HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, RunnerHandshake, - PLUGIN_TOKEN_ENV, + CadDocument, EntityType, Handle, HostRequest, HostResponse, HostToPlugin, InteractiveEvent, + PluginToHost, RunnerHandshake, PLUGIN_TOKEN_ENV, }; use crate::ipc::server::handle_plugin_request; use crate::ipc::transport::{recv, send}; +use crate::process::v4::V4Connection; use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup as OwnedRibbonGroupAlias}; use serde::de::DeserializeOwned; +use std::sync::Arc; mod manager; -pub use manager::{DispatchResult, PluginManager}; +mod v4; +pub use manager::{DispatchResult, NotificationHandler, PluginManager}; + +/// A dummy `HostApi` used for V4 paths that do not supply a real host surface +/// (e.g., interactive events). Nested plugin requests receive safe defaults. +struct NullHost; + +impl HostApi for NullHost { + fn tab_index(&self) -> usize { + 0 + } + fn document(&self) -> &CadDocument { + panic!("NullHost: document not available") + } + fn document_mut(&mut self) -> &mut CadDocument { + panic!("NullHost: document_mut not available") + } + fn document_reader(&self) -> Box { + Box::new(NullReader) + } + fn add_entity(&mut self, _entity: EntityType) -> Handle { + Handle::default() + } + fn bump_geometry(&mut self) {} + fn read_record( + &self, + _handle: Handle, + _app_name: &str, + ) -> Option<&crate::ipc::protocol::ExtendedDataRecord> { + None + } + fn write_record(&mut self, _handle: Handle, _record: crate::ipc::protocol::ExtendedDataRecord) -> bool { + false + } + fn remove_record(&mut self, _handle: Handle, _app_name: &str) -> bool { + false + } + fn push_undo(&mut self, _label: &str) {} + fn set_dirty(&mut self) {} + fn push_info(&mut self, _msg: &str) {} + fn push_output(&mut self, _msg: &str) {} + fn push_error(&mut self, _msg: &str) {} + fn start_interactive(&mut self, _command: Box) {} + 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, + ) -> &mut (dyn std::any::Any + Send + Sync) { + panic!("NullHost: ensure_plugin_state_any not available") + } +} + +struct NullReader; +impl crate::host::DocumentReader for NullReader { + fn entity_count(&self) -> usize { + 0 + } + fn for_each_entity(&self, _f: &mut dyn FnMut(crate::host::ReaderEntity<'_>)) {} + fn layer_name(&self, _handle: Handle) -> Option<&str> { + None + } + fn app_id_name(&self, _handle: Handle) -> Option<&str> { + None + } +} /// Whether verbose plugin-IPC logging is on. Off by default so a normal run /// only prints the one-line "Loaded plugin" notice; set `OCS_PLUGIN_VERBOSE=1` @@ -44,7 +122,7 @@ macro_rules! vlog { } /// Maximum time to wait for the plugin runner to connect back to the host. -const SPAWN_TIMEOUT: Duration = Duration::from_secs(10); +const SPAWN_TIMEOUT: Duration = Duration::from_secs(30); fn spawn_timeout() -> Duration { std::env::var("OCS_PLUGIN_SPAWN_TIMEOUT_SECS") @@ -74,6 +152,16 @@ fn request_timeout(kind: &'static str) -> Duration { base_max_floor(call_timeout(), kind) } +fn execute_code_timeout() -> Duration { + const DEFAULT: Duration = Duration::from_secs(60); + std::env::var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse().ok()) + .map(Duration::from_secs) + .unwrap_or(DEFAULT) + .max(DEFAULT) +} + fn base_max_floor(base: Duration, kind: &'static str) -> Duration { // Tests lower the floor via OCS_PLUGIN_TEST_FLOOR_SECS so the suite does not // wait out the real 10 s Dispatch minimum. The seam is compiled in only @@ -89,6 +177,7 @@ fn base_max_floor(base: Duration, kind: &'static str) -> Duration { "GetManifest" | "GetRibbon" => Duration::from_secs(5), "Dispatch" => Duration::from_secs(10), "InteractiveEvent" | "GetPrompt" | "NeedsEntityPick" => Duration::from_secs(2), + "ExecuteCode" => execute_code_timeout(), _ => Duration::from_secs(1), }; base.max(floor) @@ -102,6 +191,7 @@ fn request_kind(req: &HostRequest) -> &'static str { HostRequest::InteractiveEvent { .. } => "InteractiveEvent", HostRequest::GetPrompt { .. } => "GetPrompt", HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick", + HostRequest::ExecuteCode { .. } => "ExecuteCode", HostRequest::Shutdown => "Shutdown", } } @@ -114,22 +204,24 @@ pub enum PluginError { Transport(#[from] crate::ipc::transport::TransportError), #[error("plugin runner error: {0}")] Runner(String), - #[error("spawn timeout: runner did not connect within {0:?}")] - SpawnTimeout(Duration), #[error("call timeout: {request} did not respond within {duration:?}")] CallTimeout { request: &'static str, duration: Duration, }, - #[error("runner exited before connecting")] - RunnerExited, + #[error("runner exited unexpectedly during {request} ({status})")] + RunnerCrashed { + request: &'static str, + status: String, + }, #[error("unexpected response: {0:?}")] - UnexpectedResponse(HostResponse), + UnexpectedResponse(Box), } /// One spawned plugin process. pub struct PluginProcess { stream: Mutex>, + v4: Option, child: Mutex>, id: String, manifest: OwnedPluginManifest, @@ -138,7 +230,11 @@ pub struct PluginProcess { impl PluginProcess { /// Spawn the plugin cdylib in a separate process and connect to it. - pub fn spawn(cdylib_path: &Path, host: &mut dyn HostApi) -> Result { + pub fn spawn( + cdylib_path: &Path, + host: &mut dyn HostApi, + notification_handler: NotificationHandler, + ) -> Result { let socket_name = generate_socket_name(); let socket_name_ref: interprocess::local_socket::Name = socket_name .clone() @@ -156,15 +252,28 @@ impl PluginProcess { // Create the listener before spawning so the runner can connect immediately. let listener = ListenerOptions::new().name(socket_name_ref).create_sync()?; - let child = Command::new(&runner_path) + let mut child = Command::new(&runner_path) .arg("--ocs-plugin-runner") .arg(&socket_name) .arg(cdylib_path) .env(PLUGIN_TOKEN_ENV, &token) .stdin(Stdio::null()) .stdout(Stdio::null()) - .stderr(Stdio::null()) + .stderr(Stdio::piped()) .spawn()?; + let last_stderr: Arc> = Arc::new(Mutex::new(String::new())); + if let Some(stderr) = child.stderr.take() { + let last_stderr = Arc::clone(&last_stderr); + std::thread::spawn(move || { + use std::io::{BufRead, BufReader}; + for line in BufReader::new(stderr).lines().map_while(Result::ok) { + if let Ok(mut guard) = last_stderr.lock() { + *guard = line.clone(); + } + eprintln!("[runner stderr] {line}"); + } + }); + } let child = Mutex::new(Some(child)); // Accept the runner connection with a timeout so a hung/crashed runner @@ -183,13 +292,21 @@ impl PluginProcess { if let Some(child) = child.lock().unwrap_or_else(|e| e.into_inner()).take() { reap(child); } - return Err(PluginError::SpawnTimeout(spawn_timeout())); + let status = spawn_failure_status(&child, &last_stderr); + return Err(PluginError::RunnerCrashed { + request: "spawn/accept", + status, + }); } Err(mpsc::RecvTimeoutError::Disconnected) => { if let Some(child) = child.lock().unwrap_or_else(|e| e.into_inner()).take() { reap(child); } - return Err(PluginError::RunnerExited); + let status = spawn_failure_status(&child, &last_stderr); + return Err(PluginError::RunnerCrashed { + request: "spawn/accept", + status, + }); } }; @@ -207,32 +324,69 @@ impl PluginProcess { handshake_timeout, "Handshake", )?; - if let Err(e) = verify_runner_handshake(handshake, &token) { - mark_dead(&stream, &child); - return Err(e); + let is_v4 = match verify_runner_handshake(handshake, &token) { + Ok(v4) => v4, + Err(e) => { + mark_dead(&stream, &child); + return Err(e); + } + }; + + let no_op = &mut |_| {}; + let manifest; + let ribbon; + let v4: Option; + if is_v4 { + // V4 path: split the stream into reader/writer halves and start + // the V4 reader thread. The notification handler receives an empty + // plugin id until we read the manifest below; that brief window is + // fine because the plugin has not yet been asked to do work. + let stream = stream.lock().unwrap_or_else(|e| e.into_inner()).take().expect("V4 stream"); + let plugin_id_placeholder = Arc::new(std::sync::Mutex::new(String::new())); + let handler_for_v4: Arc, PluginNotification) + Send + Sync> = { + let plugin_id = Arc::clone(&plugin_id_placeholder); + let base_handler = Arc::clone(¬ification_handler); + Arc::new(move |command_id, notification| { + let id = plugin_id.lock().unwrap_or_else(|e| e.into_inner()).clone(); + base_handler(&id, command_id, notification); + }) + }; + let v4_conn = V4Connection::new(stream, handler_for_v4)?; + manifest = match v4_conn.call(host, HostRequest::GetManifest, no_op)? { + HostResponse::Manifest(m) => m, + other => return Err(PluginError::UnexpectedResponse(Box::new(other))), + }; + *plugin_id_placeholder.lock().unwrap_or_else(|e| e.into_inner()) = manifest.id.clone(); + ribbon = match v4_conn.call(host, HostRequest::GetRibbon, no_op)? { + HostResponse::Ribbon(r) => r, + other => return Err(PluginError::UnexpectedResponse(Box::new(other))), + }; + v4 = Some(v4_conn); + } else { + // The runner first answers GetManifest and GetRibbon so the host can + // build the UI without keeping the plugin object alive. + manifest = match call(&stream, &child, host, HostRequest::GetManifest, no_op)? { + HostResponse::Manifest(m) => m, + other => return Err(PluginError::UnexpectedResponse(Box::new(other))), + }; + ribbon = match call(&stream, &child, host, HostRequest::GetRibbon, no_op)? { + HostResponse::Ribbon(r) => r, + other => return Err(PluginError::UnexpectedResponse(Box::new(other))), + }; + v4 = None; } - // The runner first answers GetManifest and GetRibbon so the host can - // build the UI without keeping the plugin object alive. - let no_op = &mut |_| {}; - let manifest = match call(&stream, &child, host, HostRequest::GetManifest, no_op)? { - HostResponse::Manifest(m) => m, - other => return Err(PluginError::UnexpectedResponse(other)), - }; // Normal-run notice: just the loaded plugin's name (id + version). The // full IPC trace above/below is gated behind OCS_PLUGIN_VERBOSE. eprintln!( "Loaded plugin: {} ({} {})", manifest.name, manifest.id, manifest.version ); - let ribbon = match call(&stream, &child, host, HostRequest::GetRibbon, no_op)? { - HostResponse::Ribbon(r) => r, - other => return Err(PluginError::UnexpectedResponse(other)), - }; let id = manifest.id.clone(); Ok(Self { stream, + v4, child, id, manifest, @@ -259,22 +413,52 @@ impl PluginProcess { on_start_interactive: &mut dyn FnMut(u64), ) -> Result { vlog!("[plugin] dispatching {cmd}"); - let result = match call( - &self.stream, - &self.child, - host, - HostRequest::Dispatch { - cmd: cmd.to_string(), - }, - on_start_interactive, - )? { - HostResponse::Bool(b) => Ok(b), - other => Err(PluginError::UnexpectedResponse(other)), + let result = if let Some(v4) = &self.v4 { + match v4.call( + host, + HostRequest::Dispatch { + cmd: cmd.to_string(), + }, + on_start_interactive, + )? { + HostResponse::Bool(b) => Ok(b), + other => Err(PluginError::UnexpectedResponse(Box::new(other))), + } + } else { + match call( + &self.stream, + &self.child, + host, + HostRequest::Dispatch { + cmd: cmd.to_string(), + }, + on_start_interactive, + )? { + HostResponse::Bool(b) => Ok(b), + other => Err(PluginError::UnexpectedResponse(Box::new(other))), + } }; vlog!("[plugin] dispatch {cmd} result: {result:?}"); result } + /// Execute a REPL code snippet on a V4 plugin. The session is tied to the + /// active document tab via `host.tab_index()`. + pub fn execute_code( + &self, + host: &mut dyn HostApi, + command_id: u64, + source: CommandSource, + code: &str, + ) -> Result { + match &self.v4 { + Some(v4) => v4.execute_code(host, command_id, source, code), + None => Err(PluginError::Runner( + "ExecuteCode requires V4 protocol".into(), + )), + } + } + /// Send an interactive event for `command_id` and return the step the /// plugin command produces. Interactive events are not expected to trigger /// nested host API calls, so this path does not supply a `HostApi`. @@ -283,27 +467,39 @@ impl PluginProcess { command_id: u64, event: InteractiveEvent, ) -> Result { - self.send_request(HostRequest::InteractiveEvent { command_id, event })?; - let kind = "InteractiveEvent"; - let timeout = request_timeout(kind); - let deadline = Instant::now() + timeout; - loop { - match recv_with_deadline::( - &self.stream, - &self.child, - deadline, - timeout, - kind, - )? { - PluginToHost::Response(HostResponse::CommandStep(s)) => return Ok(s), - PluginToHost::Response(other) => { - return Err(PluginError::UnexpectedResponse(other)) - } - PluginToHost::Request(req) => { - let resp = crate::ipc::protocol::PluginResponse::Error(format!( - "unexpected nested request during interactive event: {req:?}" - )); - self.send_response(resp)?; + if let Some(v4) = &self.v4 { + let resp = v4.call( + &mut NullHost, + HostRequest::InteractiveEvent { command_id, event }, + &mut |_| {}, + )?; + match resp { + HostResponse::CommandStep(s) => Ok(*s), + other => Err(PluginError::UnexpectedResponse(Box::new(other))), + } + } else { + self.send_request(HostRequest::InteractiveEvent { command_id, event })?; + let kind = "InteractiveEvent"; + let timeout = request_timeout(kind); + let deadline = Instant::now() + timeout; + loop { + match recv_with_deadline::( + &self.stream, + &self.child, + deadline, + timeout, + kind, + )? { + PluginToHost::Response(HostResponse::CommandStep(s)) => return Ok(*s), + PluginToHost::Response(other) => { + return Err(PluginError::UnexpectedResponse(Box::new(other))) + } + PluginToHost::Request(req) => { + let resp = crate::ipc::protocol::PluginResponse::Error(format!( + "unexpected nested request during interactive event: {req:?}" + )); + self.send_response(resp)?; + } } } } @@ -311,27 +507,39 @@ impl PluginProcess { /// Ask the plugin process for the current prompt of an interactive command. pub fn get_prompt(&self, command_id: u64) -> Result { - self.send_request(HostRequest::GetPrompt { command_id })?; - let kind = "GetPrompt"; - let timeout = request_timeout(kind); - let deadline = Instant::now() + timeout; - loop { - match recv_with_deadline::( - &self.stream, - &self.child, - deadline, - timeout, - kind, - )? { - PluginToHost::Response(HostResponse::Text(s)) => return Ok(s), - PluginToHost::Response(other) => { - return Err(PluginError::UnexpectedResponse(other)) - } - PluginToHost::Request(req) => { - let resp = crate::ipc::protocol::PluginResponse::Error(format!( - "unexpected nested request during get_prompt: {req:?}" - )); - self.send_response(resp)?; + if let Some(v4) = &self.v4 { + let resp = v4.call( + &mut NullHost, + HostRequest::GetPrompt { command_id }, + &mut |_| {}, + )?; + match resp { + HostResponse::Text(s) => Ok(s), + other => Err(PluginError::UnexpectedResponse(Box::new(other))), + } + } else { + self.send_request(HostRequest::GetPrompt { command_id })?; + let kind = "GetPrompt"; + let timeout = request_timeout(kind); + let deadline = Instant::now() + timeout; + loop { + match recv_with_deadline::( + &self.stream, + &self.child, + deadline, + timeout, + kind, + )? { + PluginToHost::Response(HostResponse::Text(s)) => return Ok(s), + PluginToHost::Response(other) => { + return Err(PluginError::UnexpectedResponse(Box::new(other))) + } + PluginToHost::Request(req) => { + let resp = crate::ipc::protocol::PluginResponse::Error(format!( + "unexpected nested request during get_prompt: {req:?}" + )); + self.send_response(resp)?; + } } } } @@ -339,33 +547,48 @@ impl PluginProcess { /// Ask the plugin process whether an interactive command wants object picks. pub fn needs_entity_pick(&self, command_id: u64) -> Result { - self.send_request(HostRequest::NeedsEntityPick { command_id })?; - let kind = "NeedsEntityPick"; - let timeout = request_timeout(kind); - let deadline = Instant::now() + timeout; - loop { - match recv_with_deadline::( - &self.stream, - &self.child, - deadline, - timeout, - kind, - )? { - PluginToHost::Response(HostResponse::Bool(b)) => return Ok(b), - PluginToHost::Response(other) => { - return Err(PluginError::UnexpectedResponse(other)) - } - PluginToHost::Request(req) => { - let resp = crate::ipc::protocol::PluginResponse::Error(format!( - "unexpected nested request during needs_entity_pick: {req:?}" - )); - self.send_response(resp)?; + if let Some(v4) = &self.v4 { + let resp = v4.call( + &mut NullHost, + HostRequest::NeedsEntityPick { command_id }, + &mut |_| {}, + )?; + match resp { + HostResponse::Bool(b) => Ok(b), + other => Err(PluginError::UnexpectedResponse(Box::new(other))), + } + } else { + self.send_request(HostRequest::NeedsEntityPick { command_id })?; + let kind = "NeedsEntityPick"; + let timeout = request_timeout(kind); + let deadline = Instant::now() + timeout; + loop { + match recv_with_deadline::( + &self.stream, + &self.child, + deadline, + timeout, + kind, + )? { + PluginToHost::Response(HostResponse::Bool(b)) => return Ok(b), + PluginToHost::Response(other) => { + return Err(PluginError::UnexpectedResponse(Box::new(other))) + } + PluginToHost::Request(req) => { + let resp = crate::ipc::protocol::PluginResponse::Error(format!( + "unexpected nested request during needs_entity_pick: {req:?}" + )); + self.send_response(resp)?; + } } } } } pub fn is_alive(&self) -> bool { + if let Some(v4) = &self.v4 { + return v4.is_alive(); + } let mut guard = self.child.lock().unwrap_or_else(|e| e.into_inner()); match guard.as_mut() { Some(child) => match child.try_wait() { @@ -376,10 +599,46 @@ impl PluginProcess { } } - /// Tear down the plugin process without blocking the caller. The stream is - /// closed and the child is killed synchronously; the blocking `wait()` is - /// done in a detached background thread so the host never waits on a plugin. + /// Send a best-effort host-to-plugin notification. Requires V4; on V3 + /// processes this returns an error. + pub fn notify_plugin( + &self, + command_id: Option, + notification: HostNotification, + ) -> Result<(), PluginError> { + if let Some(v4) = &self.v4 { + v4.notify_plugin(command_id, notification) + } else { + Err(PluginError::Runner( + "notify_plugin requires V4 protocol".into(), + )) + } + } + + /// Drain any plugin→host requests that have arrived asynchronously for + /// this process. On V3 processes this is a no-op. + pub fn drain_requests( + &self, + host: &mut dyn HostApi, + on_start_interactive: &mut dyn FnMut(u64), + ) -> Result<(), PluginError> { + if let Some(v4) = &self.v4 { + v4.drain_requests(host, on_start_interactive)?; + } + Ok(()) + } + + /// Tear down the plugin process without blocking the caller. The child is + /// killed first so the socket closes, which unblocks the V4 reader thread + /// join. The stream is dropped and the blocking `wait()` is done in a + /// detached background thread so the host never waits on a plugin. pub fn shutdown(&self) { + // For V4 connections, request a graceful shutdown first so the plugin + // can tear down any child processes it created. The V4 shutdown waits + // a bounded amount of time for the runner to close its side. + if let Some(v4) = &self.v4 { + v4.shutdown(); + } let (stream, child) = self.take_resources(); drop(stream); if let Some(child) = child { @@ -412,7 +671,7 @@ impl PluginProcess { fn send_response(&self, resp: crate::ipc::protocol::PluginResponse) -> Result<(), PluginError> { let mut guard = self.stream.lock().unwrap_or_else(|e| e.into_inner()); let stream = guard.as_mut().ok_or_else(shutdown_error)?; - send(stream, &HostToPlugin::Response(resp)).map_err(Into::into) + send(stream, &HostToPlugin::Response(Box::new(resp))).map_err(Into::into) } } @@ -449,6 +708,42 @@ fn mark_dead(stream: &Mutex>, child: &Mutex>) { } } +/// Check whether a child process has already exited, returning its status if so. +fn child_status(child: &Mutex>) -> Option { + let mut guard = child.lock().unwrap_or_else(|e| e.into_inner()); + match guard.as_mut() { + Some(c) => c.try_wait().ok().flatten(), + None => None, + } +} + +/// Human-readable description of a process exit status. +fn format_exit_status(status: Option) -> String { + match status { + Some(s) if s.success() => "exited successfully".to_string(), + Some(s) => match s.code() { + Some(code) => format!("exit code {code}"), + None => "terminated by signal".to_string(), + }, + None => "unknown status".to_string(), + } +} + +/// Describe why a runner failed before/during connection, including any stderr +/// it produced. +fn spawn_failure_status( + child: &Mutex>, + last_stderr: &Mutex, +) -> String { + let mut parts = vec![format_exit_status(child_status(child))]; + if let Ok(line) = last_stderr.lock() { + if !line.is_empty() { + parts.push(format!("stderr: {line}")); + } + } + parts.join("; ") +} + /// Receive one message from the plugin runner with a deadline. /// /// A short-lived reader thread performs the blocking `recv` so that the main @@ -492,16 +787,43 @@ fn recv_with_deadline( } Ok((Err(e), stream_opt)) => { *stream.lock().unwrap_or_else(|e| e.into_inner()) = stream_opt; + let status = child_status(child); + let connection_error = matches!(e, PluginError::Transport(_) | PluginError::Io(_)); + if connection_error || status.is_some() { + let status_text = match status { + Some(status) => format_exit_status(Some(status)), + None => "connection lost before response; the plugin may have crashed or be ABI-incompatible".to_string(), + }; + return Err(PluginError::RunnerCrashed { + request, + status: format!("{status_text}; cause: {e}"), + }); + } Err(e) } Err(mpsc::RecvTimeoutError::Timeout) => { + let status = child_status(child); mark_dead(stream, child); + if let Some(status) = status { + return Err(PluginError::RunnerCrashed { + request, + status: format_exit_status(Some(status)), + }); + } Err(PluginError::CallTimeout { request, duration: timeout, }) } - Err(mpsc::RecvTimeoutError::Disconnected) => Err(shutdown_error()), + Err(mpsc::RecvTimeoutError::Disconnected) => { + if let Some(status) = child_status(child) { + return Err(PluginError::RunnerCrashed { + request, + status: format_exit_status(Some(status)), + }); + } + Err(shutdown_error()) + } } } @@ -529,11 +851,11 @@ fn call( match msg { PluginToHost::Response(resp) => return Ok(resp), PluginToHost::Request(plugin_req) => { - let resp = handle_plugin_request(host, plugin_req, on_start_interactive); + let resp = handle_plugin_request(host, *plugin_req, on_start_interactive); vlog!("[plugin] host -> runner response: {resp:?}"); let mut guard = stream.lock().unwrap_or_else(|e| e.into_inner()); let stream = guard.as_mut().ok_or_else(shutdown_error)?; - send(stream, &HostToPlugin::Response(resp))?; + send(stream, &HostToPlugin::Response(Box::new(resp)))?; } } } @@ -566,6 +888,11 @@ fn runner_executable() -> Result { ))); } } else { + // The host spawns itself in runner mode. We used to create a hard link + // with a distinct name so task managers show plugin processes + // separately, but re-creating that link on every launch triggers + // antivirus scanners on some systems and makes the first plugin spawn + // time out. Spawning the host executable directly avoids that. let host = std::env::current_exe()?; if !host.exists() { return Err(PluginError::Runner(format!( @@ -573,17 +900,7 @@ fn runner_executable() -> Result { host.display() ))); } - - // Create a hard link with a distinct name next to the host binary. This - // makes runner processes visible as separate sub-processes in task - // managers / ps, while keeping the runner the exact same binary as the - // host so they can never drift out of sync. - let runner = distinct_runner_path(&host); - let _ = std::fs::remove_file(&runner); - match std::fs::hard_link(&host, &runner) { - Ok(()) => runner, - Err(_) => host, - } + host }; *cached = Some(path.clone()); @@ -593,6 +910,7 @@ fn runner_executable() -> Result { /// Build a runner path like `-plugin-runner` in the same directory. /// Using a distinct image name lets task managers show plugin processes as /// children/sub-processes of the host instead of collapsing them into one row. +#[allow(dead_code)] fn distinct_runner_path(host: &Path) -> PathBuf { let mut runner = host.as_os_str().to_owned(); if let Some(ext) = host.extension().and_then(|s| s.to_str()) { @@ -611,16 +929,36 @@ fn distinct_runner_path(host: &Path) -> PathBuf { } /// Verify that the runner's `handshake` presents `expected_token`. +/// +/// Returns `Ok(true)` for a V4 handshake, `Ok(false)` for a V3 handshake, and +/// `Err` on token mismatch or unsupported protocol version. fn verify_runner_handshake( handshake: RunnerHandshake, expected_token: &str, -) -> Result<(), PluginError> { +) -> Result { match handshake { RunnerHandshake::Token(ref presented) if presented == expected_token => { - vlog!("[plugin] runner authenticated"); - Ok(()) + vlog!("[plugin] runner authenticated (V3)"); + Ok(false) } - RunnerHandshake::Token(_) => Err(PluginError::Runner("authentication failed".into())), + RunnerHandshake::TokenV4 { + token: ref presented, + protocol_version, + } if presented == expected_token => { + if protocol_version != 4 { + return Err(PluginError::Runner(format!( + "unsupported V4 protocol version {protocol_version}" + ))); + } + if crate::effective_max_api_version() < 4 { + return Err(PluginError::Runner( + "V4 protocol is disabled by OCS_PLUGIN_MAX_API_VERSION".into(), + )); + } + vlog!("[plugin] runner authenticated (V4)"); + Ok(true) + } + _ => Err(PluginError::Runner("authentication failed".into())), } } @@ -821,6 +1159,7 @@ mod timeout_tests { let (host_stream, runner_stream) = connected_pair(); let process = PluginProcess { stream: Mutex::new(Some(host_stream)), + v4: None, child: Mutex::new(Some(sleepy_child())), id: "test.plugin".to_string(), manifest: fake_manifest(), @@ -906,11 +1245,11 @@ mod timeout_tests { ); send( &mut peer, - &PluginToHost::Request(PluginRequest::PushInfo("hello".to_string())), + &PluginToHost::Request(Box::new(PluginRequest::PushInfo("hello".to_string()))), ) .expect("send nested request"); let resp = recv::(&mut peer).expect("read nested response"); - assert!(matches!(resp, HostToPlugin::Response(PluginResponse::Ok))); + assert!(matches!(resp, HostToPlugin::Response(ref r) if matches!(**r, PluginResponse::Ok))); send(&mut peer, &PluginToHost::Response(HostResponse::Bool(true))) .expect("send final response"); }); @@ -953,10 +1292,63 @@ mod timeout_tests { #[test] fn runner_handshake_correct_token_is_accepted() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::remove_var("OCS_PLUGIN_MAX_API_VERSION"); let result = verify_runner_handshake( RunnerHandshake::Token("correct-token".to_string()), "correct-token", ); assert!(result.is_ok(), "expected authentication success, got {result:?}"); + assert!(!result.unwrap(), "V3 token should select V3 path"); + } + + #[test] + fn runner_handshake_tokenv4_selects_v4_path() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::remove_var("OCS_PLUGIN_MAX_API_VERSION"); + let result = verify_runner_handshake( + RunnerHandshake::TokenV4 { + token: "correct-token".to_string(), + protocol_version: 4, + }, + "correct-token", + ); + assert!(result.is_ok(), "expected authentication success, got {result:?}"); + assert!(result.unwrap(), "TokenV4 should select V4 path"); + } + + #[test] + fn runner_handshake_tokenv4_mismatched_version_is_rejected() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::remove_var("OCS_PLUGIN_MAX_API_VERSION"); + let result = verify_runner_handshake( + RunnerHandshake::TokenV4 { + token: "correct-token".to_string(), + protocol_version: 5, + }, + "correct-token", + ); + assert!( + matches!(result, Err(PluginError::Runner(ref s)) if s.contains("unsupported")), + "expected unsupported version error, got {result:?}" + ); + } + + #[test] + fn runner_handshake_tokenv4_rejected_when_v4_disabled() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var("OCS_PLUGIN_MAX_API_VERSION", "3"); + let result = verify_runner_handshake( + RunnerHandshake::TokenV4 { + token: "correct-token".to_string(), + protocol_version: 4, + }, + "correct-token", + ); + assert!( + matches!(result, Err(PluginError::Runner(ref s)) if s.contains("disabled")), + "expected V4 disabled error, got {result:?}" + ); + std::env::remove_var("OCS_PLUGIN_MAX_API_VERSION"); } } diff --git a/crates/ocs_plugin_api/src/process/manager.rs b/crates/ocs_plugin_api/src/process/manager.rs index 9afde072a..54f690302 100644 --- a/crates/ocs_plugin_api/src/process/manager.rs +++ b/crates/ocs_plugin_api/src/process/manager.rs @@ -1,16 +1,19 @@ //! Process manager for out-of-process plugins. use std::path::Path; -use std::process::Child; use std::sync::Arc; -use crate::host::HostApi; +use crate::host::{HostApi, HostNotification, PluginNotification}; use crate::process::{PluginError, PluginProcess}; use crate::ribbon::owned::{to_shared_module, SharedCadModule}; +/// Callback the host installs to receive plugin-to-host notifications. +pub type NotificationHandler = Arc, PluginNotification) + Send + Sync>; + /// Owner of every spawned plugin process. pub struct PluginManager { plugins: Vec, + notification_handler: Option, } struct LoadedPlugin { @@ -42,9 +45,18 @@ impl PluginManager { pub fn new() -> Self { Self { plugins: Vec::new(), + notification_handler: None, } } + /// Install a handler for plugin-to-host notifications. The handler runs on + /// the V4 reader thread and must not block or allocate heavily; forward to + /// a channel if heavy work is required. Should be set before [`load`] is + /// called so notifications from newly loaded plugins are delivered. + pub fn set_notification_handler(&mut self, handler: NotificationHandler) { + self.notification_handler = Some(handler); + } + /// Spawn `cdylib_path` as a separate plugin process, build its ribbon /// module, and store it. Returns the plugin id on success. pub fn load( @@ -52,7 +64,11 @@ impl PluginManager { cdylib_path: &Path, host: &mut dyn HostApi, ) -> Result { - let process = PluginProcess::spawn(cdylib_path, host)?; + let handler: NotificationHandler = match self.notification_handler.as_ref() { + Some(h) => Arc::clone(h), + None => Arc::new(|_id, _cmd, _notif| {}), + }; + let process = PluginProcess::spawn(cdylib_path, host, handler)?; let id = process.id().to_string(); let name = process.manifest().name.clone(); let module = to_shared_module(id.clone(), name, process.ribbon().to_vec()); @@ -63,6 +79,26 @@ impl PluginManager { Ok(id) } + /// Fan out a host notification to every alive V4 plugin. Per-process errors + /// are logged; this is best-effort. + pub fn broadcast_notification( + &self, + command_id: Option, + notification: HostNotification, + ) { + for p in &self.plugins { + if !p.process.is_alive() { + continue; + } + if let Err(e) = p.process.notify_plugin(command_id, notification.clone()) { + eprintln!( + "[plugin] broadcast_notification failed for {}: {e}", + p.process.id() + ); + } + } + } + /// Ribbon modules for alive, non-disabled plugins, sorted by `ribbon_order`. pub fn ribbon_modules bool>( &self, @@ -140,6 +176,23 @@ impl PluginManager { out } + /// Drain any plugin→host requests that have arrived asynchronously across + /// all loaded plugins. Errors are logged; this is best-effort. + pub fn drain_requests( + &self, + host: &mut dyn HostApi, + on_start_interactive: &mut dyn FnMut(u64), + ) { + for p in &self.plugins { + if !p.process.is_alive() { + continue; + } + if let Err(e) = p.process.drain_requests(host, on_start_interactive) { + eprintln!("[plugin] drain_requests failed for {}: {e}", p.process.id()); + } + } + } + /// Begin asynchronous shutdown of every plugin process. /// /// Kills every child synchronously on the calling thread and moves the @@ -147,21 +200,8 @@ impl PluginManager { /// shutdown is fast regardless of how many plugins are loaded. pub fn shutdown_all(&mut self) { let plugins = std::mem::take(&mut self.plugins); - let mut children: Vec = Vec::with_capacity(plugins.len()); for p in plugins { - let (stream, child) = p.process.take_resources(); - drop(stream); - if let Some(mut child) = child { - let _ = child.kill(); - children.push(child); - } - } - if !children.is_empty() { - std::thread::spawn(move || { - for mut child in children { - let _ = child.wait(); - } - }); + p.process.shutdown(); } } } diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs new file mode 100644 index 000000000..1df2675d9 --- /dev/null +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -0,0 +1,743 @@ +//! Private V4 connection owned by [`PluginProcess`](crate::process::PluginProcess). + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{mpsc, Arc, Mutex}; +use std::time::{Duration, Instant}; + +use interprocess::local_socket::Stream; +use interprocess::TryClone; + +use crate::host::{CommandSource, ExecutionResult, HostApi, HostNotification, PluginNotification}; +use crate::ipc::protocol::{HostRequest, HostResponse}; +use crate::ipc::server::handle_plugin_request; +use crate::ipc::transport::send; +use crate::ipc::v4::protocol::{HostToPluginV4, NotificationEnvelope}; +use crate::ipc::v4::server::{ + default_notify_rate_limit, run_host_reader_thread, HostIncoming, RateLimiter, V4HostShared, +}; +use crate::process::PluginError; + +/// Default maximum time to wait for a plugin call to respond. +const CALL_TIMEOUT_DEFAULT: Duration = Duration::from_secs(30); + +fn call_timeout() -> Duration { + std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse().ok()) + .map(Duration::from_secs) + .unwrap_or(CALL_TIMEOUT_DEFAULT) +} + +/// Per-request-kind timeout floors. +fn request_timeout(kind: &'static str) -> Duration { + base_max_floor(call_timeout(), kind) +} + +fn execute_code_timeout() -> Duration { + const DEFAULT: Duration = Duration::from_secs(60); + std::env::var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse().ok()) + .map(Duration::from_secs) + .unwrap_or(DEFAULT) + .max(DEFAULT) +} + +fn base_max_floor(base: Duration, kind: &'static str) -> Duration { + #[cfg(test)] + if let Some(secs) = std::env::var("OCS_PLUGIN_TEST_FLOOR_SECS") + .ok() + .and_then(|s| s.parse().ok()) + { + return base.max(Duration::from_secs(secs)); + } + let floor = match kind { + "GetManifest" | "GetRibbon" => Duration::from_secs(5), + "Dispatch" => Duration::from_secs(10), + "InteractiveEvent" | "GetPrompt" | "NeedsEntityPick" => Duration::from_secs(2), + "ExecuteCode" => execute_code_timeout(), + _ => Duration::from_secs(1), + }; + base.max(floor) +} + +fn request_kind(req: &HostRequest) -> &'static str { + match req { + HostRequest::GetManifest => "GetManifest", + HostRequest::GetRibbon => "GetRibbon", + HostRequest::Dispatch { .. } => "Dispatch", + HostRequest::InteractiveEvent { .. } => "InteractiveEvent", + HostRequest::GetPrompt { .. } => "GetPrompt", + HostRequest::NeedsEntityPick { .. } => "NeedsEntityPick", + HostRequest::ExecuteCode { .. } => "ExecuteCode", + HostRequest::Shutdown => "Shutdown", + } +} + +pub(crate) struct V4Connection { + shared: Arc, + incoming: Mutex>, + next_id: AtomicU64, + reader_handle: Mutex>>, +} + +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, PluginNotification) + Send + Sync>, + ) -> Result { + let reader = stream.try_clone().map_err(|e| { + PluginError::Io(std::io::Error::other(format!( + "V4 stream try_clone failed: {e}" + ))) + })?; + + let shared = Arc::new(V4HostShared { + writer: Mutex::new(Some(stream)), + alive: AtomicBool::new(true), + }); + + let (incoming_tx, incoming_rx) = mpsc::channel(); + let rate_limiter = RateLimiter::new(default_notify_rate_limit()); + + let shared_for_thread = Arc::clone(&shared); + let handle = std::thread::spawn(move || { + run_host_reader_thread( + reader, + shared_for_thread, + incoming_tx, + handler, + rate_limiter, + ) + }); + + Ok(Self { + shared, + incoming: Mutex::new(incoming_rx), + next_id: AtomicU64::new(1), + reader_handle: Mutex::new(Some(handle)), + }) + } + + pub fn is_alive(&self) -> bool { + self.shared.alive.load(Ordering::SeqCst) + } + + /// Send a host-to-plugin notification. + #[allow(clippy::result_large_err)] + pub fn notify_plugin( + &self, + command_id: Option, + 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 { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let kind = request_kind(&req); + let timeout = request_timeout(kind); + let deadline = Instant::now() + timeout; + + { + let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner()); + let stream = writer.as_mut().ok_or_else(|| { + PluginError::Io(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "V4 connection is shut down", + )) + })?; + send(stream, &HostToPluginV4::Request { id, payload: req })?; + } + + let incoming = self.incoming.lock().unwrap_or_else(|e| e.into_inner()); + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + self.shared.alive.store(false, Ordering::SeqCst); + return Err(PluginError::CallTimeout { + request: kind, + duration: timeout, + }); + } + match incoming.recv_timeout(remaining) { + Ok(HostIncoming::Response { id: rid, payload }) if rid == id => return Ok(payload), + Ok(HostIncoming::Response { payload, .. }) => { + return Err(PluginError::UnexpectedResponse(Box::new(payload))) + } + Ok(HostIncoming::Request { id: rid, payload }) => { + let resp = handle_plugin_request(host, *payload, on_start_interactive); + let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner()); + let stream = writer.as_mut().ok_or_else(|| { + PluginError::Io(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "V4 connection is shut down", + )) + })?; + send(stream, &HostToPluginV4::Response { id: rid, payload: resp })?; + } + Err(mpsc::RecvTimeoutError::Timeout) => { + self.shared.alive.store(false, Ordering::SeqCst); + return Err(PluginError::CallTimeout { + request: kind, + duration: timeout, + }); + } + Err(mpsc::RecvTimeoutError::Disconnected) => { + self.shared.alive.store(false, Ordering::SeqCst); + return Err(PluginError::Runner( + "V4 reader thread disconnected".into(), + )); + } + } + } + } + + /// Drain any plugin→host requests that have arrived since the last call and + /// apply them using `host`. This lets long-lived plugin sessions (e.g. a + /// Python REPL) mutate the host document while the host is not otherwise in + /// a plugin call. + #[allow(clippy::result_large_err)] + pub fn drain_requests( + &self, + host: &mut dyn HostApi, + on_start_interactive: &mut dyn FnMut(u64), + ) -> Result<(), PluginError> { + let incoming = self.incoming.lock().unwrap_or_else(|e| e.into_inner()); + loop { + match incoming.try_recv() { + Ok(HostIncoming::Request { id, payload }) => { + let resp = handle_plugin_request(host, *payload, on_start_interactive); + let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner()); + let stream = writer.as_mut().ok_or_else(|| { + PluginError::Io(std::io::Error::new( + std::io::ErrorKind::NotConnected, + "V4 connection is shut down", + )) + })?; + send(stream, &HostToPluginV4::Response { id, payload: resp })?; + } + Ok(HostIncoming::Response { .. }) => { + // Responses without a matching active call should not + // happen; drop them to avoid blocking the queue. + continue; + } + Err(mpsc::TryRecvError::Empty) => return Ok(()), + Err(mpsc::TryRecvError::Disconnected) => { + self.shared.alive.store(false, Ordering::SeqCst); + return Err(PluginError::Runner( + "V4 reader thread disconnected".into(), + )); + } + } + } + } + + /// Send an `ExecuteCode` request and block until the plugin returns the + /// result. The active document tab index is included so the REPL session is + /// tied to the tab that issued it. + #[allow(clippy::result_large_err)] + pub fn execute_code( + &self, + host: &mut dyn HostApi, + command_id: u64, + source: CommandSource, + code: &str, + ) -> Result { + let req = HostRequest::ExecuteCode { + command_id, + source, + code: code.to_string(), + tab_index: host.tab_index(), + }; + match self.call(host, req, &mut |_| {})? { + HostResponse::CodeExecutionResult(result) => Ok(result), + other => Err(PluginError::UnexpectedResponse(Box::new(other))), + } + } + + /// Shut down the V4 connection. The writer half is dropped, which causes + /// the reader thread to exit, then the thread handle is joined. + pub fn shutdown(&self) { + { + let mut writer = self.shared.writer.lock().unwrap_or_else(|e| e.into_inner()); + // Ask the runner to shut down gracefully so the plugin has a chance + // to clean up spawned child processes (e.g. the Python REPL). The + // runner will send an Ack, then observe EOF on the next loop + // iteration and exit. + if let Some(stream) = writer.as_mut() { + let id = self.next_id.fetch_add(1, Ordering::Relaxed); + let _ = send( + stream, + &HostToPluginV4::Request { + id, + payload: HostRequest::Shutdown, + }, + ); + } + let _ = writer.take(); + } + // Dropping the writer closes the socket. The reader thread will then + // exit and clear `alive`. Joining can deadlock if a plugin child + // inherited the socket handle, so we only poll briefly. + let deadline = Instant::now() + Duration::from_secs(3); + while self.shared.alive.load(Ordering::SeqCst) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(50)); + } + let _ = self.reader_handle.lock().unwrap_or_else(|e| e.into_inner()).take(); + } +} + +#[cfg(all(test, feature = "host"))] +mod tests { + use super::*; + use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; + use std::thread; + + use crate::host::{CommandSource, DocumentReader, ExecutionResult, ReaderEntity}; + use crate::ipc::protocol::{HostResponse, PluginRequest, PluginResponse}; + use crate::ipc::transport::recv; + use crate::ipc::v4::protocol::{HostToPluginV4, PluginToHostV4}; + use acadrust::{CadDocument, Handle}; + use interprocess::local_socket::{ + traits::{Listener, Stream as StreamTrait}, + GenericNamespaced, ListenerOptions, ToNsName, + }; + + fn unique_socket_name() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("ocs_plugin_v4_proc_test_{}_{}", std::process::id(), n) + } + + fn connect_pair() -> (Stream, Stream) { + let name = unique_socket_name(); + let name_ref = name + .clone() + .to_ns_name::() + .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::().unwrap()) + .expect("connect") + }); + let server = listener.accept().expect("accept"); + let client = client_thread.join().expect("client thread"); + (server, client) + } + + struct EmptyReader; + impl DocumentReader for EmptyReader { + fn entity_count(&self) -> usize { + 0 + } + fn for_each_entity(&self, _f: &mut dyn FnMut(ReaderEntity<'_>)) {} + fn layer_name(&self, _handle: Handle) -> Option<&str> { + None + } + fn app_id_name(&self, _handle: Handle) -> Option<&str> { + None + } + } + + struct DummyHost; + impl HostApi for DummyHost { + fn tab_index(&self) -> usize { + 0 + } + fn document(&self) -> &CadDocument { + panic!("not used") + } + fn document_mut(&mut self) -> &mut CadDocument { + panic!("not used") + } + fn document_reader(&self) -> Box { + Box::new(EmptyReader) + } + fn add_entity(&mut self, _entity: acadrust::EntityType) -> Handle { + panic!("not used") + } + fn bump_geometry(&mut self) {} + fn read_record( + &self, + _handle: Handle, + _app_name: &str, + ) -> Option<&acadrust::xdata::ExtendedDataRecord> { + None + } + fn write_record( + &mut self, + _handle: Handle, + _record: acadrust::xdata::ExtendedDataRecord, + ) -> bool { + false + } + fn remove_record(&mut self, _handle: Handle, _app_name: &str) -> bool { + false + } + fn push_undo(&mut self, _label: &str) {} + fn set_dirty(&mut self) {} + fn push_info(&mut self, _msg: &str) {} + fn push_output(&mut self, _msg: &str) {} + fn push_error(&mut self, _msg: &str) {} + fn start_interactive(&mut self, _command: Box) {} + 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, + ) -> &mut (dyn std::any::Any + Send + Sync) { + panic!("not used") + } + } + + use std::sync::Mutex as StdMutex; + + static ENV_LOCK: StdMutex<()> = StdMutex::new(()); + + fn set_test_env() { + std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", "2"); + std::env::set_var("OCS_PLUGIN_TEST_FLOOR_SECS", "0"); + } + + fn restore_test_env() { + std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"); + std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS"); + } + + #[test] + fn execute_code_timeout_floor_is_at_least_60s_and_env_override() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"); + assert!( + request_timeout("ExecuteCode") >= Duration::from_secs(60), + "ExecuteCode floor should be at least 60 s" + ); + + std::env::set_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS", "120"); + assert_eq!( + request_timeout("ExecuteCode"), + Duration::from_secs(120), + "env override should be respected" + ); + std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"); + } + + #[test] + fn v4_connection_request_response() { + let _guard = ENV_LOCK.lock().unwrap(); + set_test_env(); + let (host_stream, mut runner_stream) = connect_pair(); + let handler: Arc, 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::(&mut runner_stream).unwrap(); + match req { + HostToPluginV4::Request { id, payload: HostRequest::Dispatch { cmd } } => { + assert_eq!(cmd, "HELLO"); + send( + &mut runner_stream, + &PluginToHostV4::Response { + id, + payload: HostResponse::Bool(true), + }, + ) + .unwrap(); + } + other => panic!("unexpected: {other:?}"), + } + }); + + let mut host = DummyHost; + let resp = conn + .call(&mut host, HostRequest::Dispatch { cmd: "HELLO".to_string() }, &mut |_| {}) + .unwrap(); + assert!(matches!(resp, HostResponse::Bool(true))); + runner.join().unwrap(); + restore_test_env(); + } + + #[test] + fn v4_connection_handles_nested_plugin_request() { + let _guard = ENV_LOCK.lock().unwrap(); + set_test_env(); + let (host_stream, mut runner_stream) = connect_pair(); + let handler: Arc, PluginNotification) + Send + Sync> = + Arc::new(|_, _| {}); + let conn = V4Connection::new(host_stream, handler).unwrap(); + + let runner = thread::spawn(move || { + let req = recv::(&mut runner_stream).unwrap(); + match req { + HostToPluginV4::Request { id, payload: HostRequest::Dispatch { .. } } => { + // Send a nested plugin request. + send( + &mut runner_stream, + &PluginToHostV4::Request { + id: 99, + payload: PluginRequest::PushInfo("nested".to_string()), + }, + ) + .unwrap(); + // Read the response to the nested request. + let resp = recv::(&mut runner_stream).unwrap(); + match resp { + HostToPluginV4::Response { id: rid, payload: PluginResponse::Ok } + if rid == 99 => {} + other => panic!("unexpected nested response: {other:?}"), + } + // Now respond to the original dispatch. + send( + &mut runner_stream, + &PluginToHostV4::Response { + id, + payload: HostResponse::Bool(true), + }, + ) + .unwrap(); + } + other => panic!("unexpected: {other:?}"), + } + }); + + let mut host = DummyHost; + let resp = conn + .call(&mut host, HostRequest::Dispatch { cmd: "NESTED".to_string() }, &mut |_| {}) + .unwrap(); + assert!(matches!(resp, HostResponse::Bool(true))); + runner.join().unwrap(); + restore_test_env(); + } + + #[test] + fn v4_connection_notification_invokes_handler() { + let _guard = ENV_LOCK.lock().unwrap(); + set_test_env(); + use crate::host::LogLevel; + + let (host_stream, mut runner_stream) = connect_pair(); + let received = Arc::new(Mutex::new(Vec::new())); + let received2 = Arc::clone(&received); + let handler: Arc, 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, 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, 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, PluginNotification) + Send + Sync> = + Arc::new(|_, _| {}); + let conn = V4Connection::new(host_stream, handler).unwrap(); + + let runner = thread::spawn(move || { + let req = recv::(&mut runner_stream).unwrap(); + match req { + HostToPluginV4::Request { + id, + payload: + HostRequest::ExecuteCode { + command_id, + source, + code, + tab_index, + }, + } => { + assert_eq!(command_id, 1); + assert_eq!(source, CommandSource::Editor); + assert_eq!(code, "1+1"); + assert_eq!(tab_index, 0, "ExecuteCode should be tied to the host's tab index"); + thread::sleep(Duration::from_millis(100)); + send( + &mut runner_stream, + &PluginToHostV4::Response { + id, + payload: HostResponse::CodeExecutionResult(ExecutionResult { + success: true, + output: Some("42".to_string()), + error: None, + error_type: None, + traceback: None, + line_number: None, + column_number: None, + duration_ms: 100.0, + }), + }, + ) + .unwrap(); + } + other => panic!("unexpected: {other:?}"), + } + }); + + let mut host = DummyHost; + let result = conn + .execute_code(&mut host, 1, CommandSource::Editor, "1+1") + .expect("execute_code should succeed"); + assert!(result.success); + assert_eq!(result.output, Some("42".to_string())); + runner.join().unwrap(); + restore_test_env(); + } +} diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index 952e3bdc5..ca62629ba 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -16,6 +16,7 @@ use crate::ipc::protocol::{ HostRequest, HostResponse, HostToPlugin, InteractiveEvent, PluginToHost, PLUGIN_TOKEN_ENV, }; use crate::ipc::transport::{recv, send}; +use crate::ipc::v4::client::{RunnerFrame, V4Client}; use crate::ribbon::owned::OwnedRibbonGroup; /// Entry point for the plugin runner child process. @@ -26,7 +27,11 @@ use crate::ribbon::owned::OwnedRibbonGroup; /// fatal error so the child does not fall through to the host's GUI main. pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box> { eprintln!("[runner] starting for {cdylib_path:?} on {socket_name}"); - let plugin = unsafe { load_plugin(cdylib_path)? }; + // Let the plugin locate its own cdylib so it can copy it into generated + // workspaces (e.g., as a Python extension). This is set only in the child + // runner process, so it does not leak into the host environment. + std::env::set_var("OCS_PLUGIN_CDYLIB_PATH", cdylib_path.as_os_str()); + let (version, mut plugin) = unsafe { load_plugin(cdylib_path)? }; let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new())); let token = match std::env::var(PLUGIN_TOKEN_ENV) { @@ -37,29 +42,68 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box= 4 { + let client = V4Client::connect_handshake(socket_name, &token)?; + run_v4(&mut *plugin, &interactive, client) + } else { + let client = IpcClient::connect(socket_name)?; + eprintln!("[runner] connected to host"); + client.send_handshake(&token)?; + run_v3(&*plugin, &interactive, client) + } +} +fn run_v3( + plugin: &dyn BuiltinPlugin, + interactive: &InteractiveRegistry, + client: IpcClient, +) -> Result<(), Box> { loop { let msg: HostToPlugin = recv(&mut client.stream_ref())?; eprintln!("[runner] host -> runner: {msg:?}"); match msg { HostToPlugin::Request(req) => { - let resp = handle_host_request(&*plugin, &interactive, &client, req); + let resp = handle_host_request(plugin, interactive, &client, req); eprintln!("[runner] runner -> host: {resp:?}"); send(&mut client.stream_ref(), &PluginToHost::Response(resp))?; } HostToPlugin::Response(_) => { - // Responses are consumed by PluginHostApi::request synchronously. - // Reaching here means the host sent a response without a pending - // plugin request. eprintln!("[runner] unexpected HostToPlugin::Response"); } } } } +fn run_v4( + plugin: &mut dyn BuiltinPlugin, + interactive: &InteractiveRegistry, + client: V4Client, +) -> Result<(), Box> { + loop { + // Drain any host notifications and dispatch them to the plugin's + // state-only callback. Panics are caught so a bad callback does not + // tear down the connection. + while let Some((command_id, notification)) = client.try_recv_notification() { + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + plugin.on_notification(command_id, notification) + })); + } + + match client.recv_runner_frame() { + Ok(RunnerFrame::Request { id, payload }) => { + if let Some(resp) = handle_host_request_v4(&mut *plugin, interactive, &client, id, payload) { + client.send_response(id, resp)?; + } + } + Err(_) => { + // Host disconnected. + break; + } + } + } + Ok(()) +} + fn handle_host_request( plugin: &dyn BuiltinPlugin, interactive: &InteractiveRegistry, @@ -115,7 +159,7 @@ fn handle_host_request( })) }; match step { - Ok(s) => HostResponse::CommandStep(s), + Ok(s) => HostResponse::CommandStep(Box::new(s)), Err(_) => HostResponse::Error("interactive command panicked".to_string()), } } @@ -147,14 +191,143 @@ fn handle_host_request( None => HostResponse::Error(format!("unknown interactive command {command_id}")), } } + HostRequest::ExecuteCode { .. } => { + HostResponse::Error("ExecuteCode requires V4 protocol".to_string()) + } HostRequest::Shutdown => { - // The runner will exit after this response is sent. - std::process::exit(0); + // Return a positive Ack so the host knows we received the request. + // run_v3 will then observe the closed stream, break its loop, and + // return, allowing the plugin object to be dropped and clean up + // spawned child processes before the runner process exits. + HostResponse::Bool(true) } } } -unsafe fn load_plugin(path: &Path) -> Result, Box> { +fn handle_host_request_v4( + plugin: &mut dyn BuiltinPlugin, + interactive: &InteractiveRegistry, + client: &V4Client, + id: u64, + req: HostRequest, +) -> Option { + match req { + HostRequest::GetManifest => { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.manifest())) { + Ok(m) => Some(HostResponse::Manifest(m.into())), + Err(_) => Some(HostResponse::Error("plugin manifest() panicked".to_string())), + } + } + HostRequest::GetRibbon => { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.ribbon())) { + Ok(groups) => Some(HostResponse::Ribbon( + groups + .ribbon_groups() + .iter() + .map(OwnedRibbonGroup::from) + .collect(), + )), + Err(_) => Some(HostResponse::Error("plugin ribbon() panicked".to_string())), + } + } + HostRequest::Dispatch { cmd } => { + let mut proxy = client.plugin_host_api(0, interactive.clone()); + let handled = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + plugin.dispatch(&mut proxy, &cmd) + })); + match handled { + Ok(b) => Some(HostResponse::Bool(b)), + Err(_) => Some(HostResponse::Error("plugin dispatch panicked".to_string())), + } + } + HostRequest::InteractiveEvent { command_id, event } => { + let step = { + let mut registry = interactive.borrow_mut(); + let Some(cmd) = registry.get_mut(&command_id) else { + return Some(HostResponse::Error(format!( + "unknown interactive command {command_id}" + ))); + }; + let cmd_ref: &mut dyn InteractiveCommand = cmd.as_mut(); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match event { + InteractiveEvent::Point(pt) => cmd_ref.on_point(pt), + InteractiveEvent::Enter => cmd_ref.on_enter(), + InteractiveEvent::ObjectPick { handle, pt } => { + cmd_ref.on_object_pick(handle, pt) + } + })) + }; + match step { + Ok(s) => Some(HostResponse::CommandStep(Box::new(s))), + Err(_) => Some(HostResponse::Error("interactive command panicked".to_string())), + } + } + HostRequest::GetPrompt { command_id } => { + let result = { + let registry = interactive.borrow(); + registry.get(&command_id).map(|cmd| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| cmd.prompt())) + }) + }; + match result { + Some(Ok(s)) => Some(HostResponse::Text(s)), + Some(Err(_)) => Some(HostResponse::Error("prompt() panicked".to_string())), + None => Some(HostResponse::Error(format!("unknown interactive command {command_id}"))), + } + } + HostRequest::NeedsEntityPick { command_id } => { + let result = { + let registry = interactive.borrow(); + registry.get(&command_id).map(|cmd| { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + cmd.needs_object_pick() + })) + }) + }; + match result { + Some(Ok(b)) => Some(HostResponse::Bool(b)), + Some(Err(_)) => Some(HostResponse::Error("needs_object_pick() panicked".to_string())), + None => Some(HostResponse::Error(format!("unknown interactive command {command_id}"))), + } + } + HostRequest::ExecuteCode { + command_id, + source, + code, + tab_index, + } => { + let mut proxy = client.plugin_host_api(tab_index, interactive.clone()); + let respond = client.execute_code_responder(id); + let started = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + plugin.start_execute_code( + &mut proxy, + command_id, + &code, + source, + respond, + ) + })); + match started { + Ok(true) => None, + Ok(false) => Some(HostResponse::Error( + "plugin does not support code execution".to_string(), + )), + Err(_) => Some(HostResponse::Error( + "plugin start_execute_code panicked".to_string(), + )), + } + } + HostRequest::Shutdown => { + // Return a positive Ack so the host knows we received the request. + // run_v4 will then observe the closed V4 writer, break its loop, + // and return, allowing the plugin object to be dropped and clean up + // spawned child processes before the runner process exits. + Some(HostResponse::Bool(true)) + } + } +} + +unsafe fn load_plugin(path: &Path) -> Result<(u32, Box), Box> { let lib = libloading::Library::new(path)?; let version: libloading::Symbol u32> = lib @@ -165,7 +338,7 @@ unsafe fn load_plugin(path: &Path) -> Result, Box Result, Box &'static str { + "test.repl" + } + fn title(&self) -> &'static str { + "Test" + } + fn ribbon_groups(&self) -> &[RibbonGroup] { + &[] + } + } + + struct UnsupportedReplPlugin; + impl BuiltinPlugin for UnsupportedReplPlugin { + fn manifest(&self) -> &'static PluginManifest { + &TEST_MANIFEST + } + fn ribbon(&self) -> Box { + Box::new(EmptyModule) + } + fn dispatch(&self, _host: &mut dyn HostApi, _cmd: &str) -> bool { + false + } + } + + fn unique_socket_name() -> String { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + format!("ocs_plugin_runner_test_{}_{}", std::process::id(), n) + } + + fn connect_pair() -> (Stream, Stream) { + let name = unique_socket_name(); + let name_ref = name + .clone() + .to_ns_name::() + .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::().unwrap()) + .expect("connect") + }); + let server = listener.accept().expect("accept"); + let client = client_thread.join().expect("client thread"); + (server, client) + } + + #[test] + fn v4_runner_execute_code_unsupported_returns_error() { + let (host_stream, runner_stream) = connect_pair(); + let client = V4Client::from_stream_for_test(runner_stream); + let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new())); + let mut plugin = UnsupportedReplPlugin; + let req = HostRequest::ExecuteCode { + command_id: 1, + source: CommandSource::Editor, + code: "1+1".to_string(), + tab_index: 0, + }; + let resp = handle_host_request_v4(&mut plugin, &interactive, &client, 99, req); + match resp { + Some(HostResponse::Error(msg)) => { + assert!(msg.contains("does not support code execution")); + } + other => panic!("expected unsupported error, got {other:?}"), + } + drop(host_stream); + } } diff --git a/crates/ocs_plugin_api/src/shm.rs b/crates/ocs_plugin_api/src/shm.rs index bd04ad7c9..0e889cdfb 100644 --- a/crates/ocs_plugin_api/src/shm.rs +++ b/crates/ocs_plugin_api/src/shm.rs @@ -4,6 +4,10 @@ //! rkyv-serialized view of the active document. The plugin maps the same file //! read-only and reads entity/layer data directly from the mapping without //! copying the full `CadDocument` into its own address space. +//! +//! This module is generic over the payload type so that the V3 simplified view +//! and the V4 bincode-per-entity view can share the same double-buffered +//! control-page logic. use std::fs::OpenOptions; use std::io; @@ -33,29 +37,51 @@ pub struct DocumentViewInfo { pub version: u64, } +/// Marker trait for types that can be placed in a shared-memory snapshot. +/// +/// Implementers provide rkyv serialization/deserialization without exposing +/// rkyv's generic serializer types in the public API. +pub trait SnapshotData: Clone + std::fmt::Debug + Send + Sync + 'static { + /// The archived (zero-copy) type returned by [`Self::check_bytes`]. + type Archived: 'static; + + /// Serialize `self` into rkyv bytes. + fn to_rkyv_bytes(&self) -> io::Result>; + + /// Validate `bytes` and return a reference to the archived type. + fn check_rkyv_bytes(bytes: &[u8]) -> Option<&Self::Archived>; +} + /// Host-side, file-backed double buffer for the document view. -pub struct DocumentSnapshotStore { +pub struct DocumentSnapshotStore { path: PathBuf, mmap: MmapMut, segment_size: usize, current_version: u64, + _phantom: std::marker::PhantomData, } -impl DocumentSnapshotStore { - /// Create a new store for `tab`. `segment_size` is the maximum size of one +impl DocumentSnapshotStore { + /// Create a new store for `tab_id`. `segment_size` is the maximum size of one /// snapshot buffer; the file is sized to hold two segments plus the control /// page. - pub fn new(tab: usize, segment_size: usize) -> io::Result { + pub fn new(tab_id: u64, segment_size: usize) -> io::Result { let segment_size = segment_size.next_multiple_of(4096); static STORE_ID: AtomicUsize = AtomicUsize::new(0); let id = STORE_ID.fetch_add(1, Ordering::Relaxed); - let path = Self::temp_path(tab, id); + let path = Self::temp_path(tab_id, id); let file = OpenOptions::new() .read(true) .write(true) .create(true) .truncate(true) .open(&path)?; + #[cfg(unix)] + { + use std::fs::Permissions; + use std::os::unix::fs::PermissionsExt; + file.set_permissions(Permissions::from_mode(0o600))?; + } let total = CONTROL_SIZE + 2 * segment_size; file.set_len(total as u64)?; @@ -72,15 +98,16 @@ impl DocumentSnapshotStore { mmap, segment_size, current_version: 0, + _phantom: std::marker::PhantomData, }) } - fn temp_path(tab: usize, id: usize) -> PathBuf { + fn temp_path(tab_id: u64, id: usize) -> PathBuf { let mut path = std::env::temp_dir(); path.push(format!( - "ocs_plugin_doc_{}_{}_{}_{}.bin", + "ocs_plugin_doc_v4_{}_{}_{}_{}.bin", std::process::id(), - tab, + tab_id, std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -95,12 +122,9 @@ impl DocumentSnapshotStore { &self.path } - /// Serialize `doc` into the inactive segment and atomically publish it. - pub fn publish(&mut self, doc: &CadDocument) -> io::Result<()> { - let data = DocumentViewData::from(doc); - let bytes = to_bytes::<_, 256>(&data).map_err(|e| { - io::Error::new(io::ErrorKind::InvalidData, format!("rkyv serialize: {e}")) - })?; + /// Serialize `data` into the inactive segment and atomically publish it. + pub fn publish(&mut self, data: &T) -> io::Result<()> { + let bytes = data.to_rkyv_bytes()?; if bytes.len() > self.segment_size { return Err(io::Error::new( io::ErrorKind::OutOfMemory, @@ -143,20 +167,21 @@ impl DocumentSnapshotStore { } } -impl Drop for DocumentSnapshotStore { +impl Drop for DocumentSnapshotStore { fn drop(&mut self) { let _ = std::fs::remove_file(&self.path); } } /// Plugin-side read-only mapping of the host's document view. -pub struct SharedDocumentReader { +pub struct SharedDocumentReader { mmap: Mmap, segment_size: usize, cached_version: u64, + _phantom: std::marker::PhantomData, } -impl SharedDocumentReader { +impl SharedDocumentReader { /// Open the file at `path` read-only and map it. The mapping may initially /// contain no valid snapshot; the caller should `refresh()` before use. pub fn open(path: &Path) -> io::Result { @@ -172,6 +197,7 @@ impl SharedDocumentReader { mmap, segment_size, cached_version: 0, + _phantom: std::marker::PhantomData, }) } @@ -201,13 +227,28 @@ impl SharedDocumentReader { &self.mmap[offset..offset + len] } - fn archived(&self) -> Option<&ArchivedDocumentViewData> { + fn archived(&self) -> Option<&T::Archived> { let bytes = self.active_segment_bytes(); - check_archived_root::(bytes).ok() + T::check_rkyv_bytes(bytes) } } -impl DocumentReader for SharedDocumentReader { +impl SharedDocumentReader { + /// Public accessor for the archived payload. Used by V4 consumers that + /// need typed access to `DocumentViewDataV4` entities. + pub fn payload(&self) -> Option<&T::Archived> { + self.archived() + } +} + +impl SharedDocumentReader { + /// V3 convenience accessor for the entity count. + pub fn entity_count(&self) -> usize { + self.archived().map(|doc| doc.entities.len()).unwrap_or(0) + } +} + +impl DocumentReader for SharedDocumentReader { fn entity_count(&self) -> usize { self.archived().map(|doc| doc.entities.len()).unwrap_or(0) } @@ -251,6 +292,12 @@ impl DocumentReader for SharedDocumentReader { } } +/// V3 alias: host-side snapshot store for the simplified document view. +pub type DocumentSnapshotStoreV3 = DocumentSnapshotStore; + +/// V3 alias: plugin-side reader for the simplified document view. +pub type SharedDocumentReaderV3 = SharedDocumentReader; + /// Raw control page shared between host and plugin. #[repr(C, align(8))] struct ControlPage { @@ -276,6 +323,8 @@ impl ControlPage { } } +// ── V3 simplified document view ───────────────────────────────────────────── + /// Serializable document view. This is the only data type placed in shared /// memory, so it must contain no pointers into host memory. #[derive(Archive, Serialize, Deserialize, Debug, Clone)] @@ -296,6 +345,20 @@ impl From<&CadDocument> for DocumentViewData { } } +impl SnapshotData for DocumentViewData { + type Archived = ArchivedDocumentViewData; + + fn to_rkyv_bytes(&self) -> io::Result> { + to_bytes::<_, 256>(self) + .map(|av| av.into_vec()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string())) + } + + fn check_rkyv_bytes(bytes: &[u8]) -> Option<&Self::Archived> { + check_archived_root::(bytes).ok() + } +} + #[derive(Archive, Serialize, Deserialize, Debug, Clone)] #[archive(check_bytes)] pub struct LayerView { @@ -395,6 +458,59 @@ impl ReaderEntityKind { } } +// ── V4 full-entity document view ──────────────────────────────────────────── + +/// Serializable V4 document view. The outer structure is rkyv; each entity's +/// `data` is a bincode-encoded `acadrust::EntityType` so that the plugin can +/// reconstruct the full typed entity without relying on acadrust's own rkyv +/// support. +#[derive(Archive, Serialize, Deserialize, Debug, Clone)] +#[archive(check_bytes)] +pub struct DocumentViewDataV4 { + pub layers: Vec, + pub app_ids: Vec, + pub entities: Vec, +} + +impl From<&CadDocument> for DocumentViewDataV4 { + fn from(doc: &CadDocument) -> Self { + Self { + layers: doc.layers.iter().map(LayerView::from).collect(), + app_ids: doc.app_ids.iter().map(AppIdView::from).collect(), + entities: doc.entities().map(EntityViewV4::from).collect(), + } + } +} + +impl SnapshotData for DocumentViewDataV4 { + type Archived = ArchivedDocumentViewDataV4; + + fn to_rkyv_bytes(&self) -> io::Result> { + to_bytes::<_, 256>(self) + .map(|av| av.into_vec()) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e.to_string())) + } + + fn check_rkyv_bytes(bytes: &[u8]) -> Option<&Self::Archived> { + check_archived_root::(bytes).ok() + } +} + +#[derive(Archive, Serialize, Deserialize, Debug, Clone)] +#[archive(check_bytes)] +pub struct EntityViewV4 { + pub handle: u64, + pub data: Vec, +} + +impl From<&EntityType> for EntityViewV4 { + fn from(entity: &EntityType) -> Self { + let handle = entity.common().handle.value(); + let data = bincode::serialize(entity).unwrap_or_default(); + Self { handle, data } + } +} + #[cfg(test)] mod tests { use super::*; @@ -415,10 +531,10 @@ mod tests { #[test] fn shared_document_reader_roundtrip() { let doc = sample_doc(); - let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap(); - store.publish(&doc).unwrap(); + let mut store = DocumentSnapshotStore::::new(0, 1024 * 1024).unwrap(); + store.publish(&(&doc).into()).unwrap(); - let reader = SharedDocumentReader::open(store.path()).unwrap(); + let reader = SharedDocumentReader::::open(store.path()).unwrap(); assert_eq!(reader.entity_count(), 1); let mut seen = Vec::new(); @@ -445,17 +561,17 @@ mod tests { #[test] fn shared_document_reader_updates_after_publish() { let doc = sample_doc(); - let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap(); - store.publish(&doc).unwrap(); + let mut store = DocumentSnapshotStore::::new(0, 1024 * 1024).unwrap(); + store.publish(&(&doc).into()).unwrap(); - let reader = SharedDocumentReader::open(store.path()).unwrap(); + let reader = SharedDocumentReader::::open(store.path()).unwrap(); assert_eq!(reader.entity_count(), 1); let mut doc2 = doc; let mut point2 = Point::from_coords(1.0, 2.0, 3.0); point2.common.layer = "SURVEY".to_string(); doc2.add_entity(EntityType::Point(point2)).unwrap(); - store.publish(&doc2).unwrap(); + store.publish(&(&doc2).into()).unwrap(); assert_eq!(reader.entity_count(), 2); } @@ -463,11 +579,28 @@ mod tests { #[test] fn layer_name_lookup_by_handle() { let doc = sample_doc(); - let mut store = DocumentSnapshotStore::new(0, 1024 * 1024).unwrap(); - store.publish(&doc).unwrap(); + let mut store = DocumentSnapshotStore::::new(0, 1024 * 1024).unwrap(); + store.publish(&(&doc).into()).unwrap(); let survey = doc.layers.iter().find(|l| l.name == "SURVEY").unwrap(); - let reader = SharedDocumentReader::open(store.path()).unwrap(); + let reader = SharedDocumentReader::::open(store.path()).unwrap(); assert_eq!(reader.layer_name(survey.handle), Some("SURVEY")); } + + #[test] + fn v4_document_view_roundtrip() { + let doc = sample_doc(); + let mut store = DocumentSnapshotStore::::new(7, 1024 * 1024).unwrap(); + let view: DocumentViewDataV4 = (&doc).into(); + store.publish(&view).unwrap(); + + let reader = SharedDocumentReader::::open(store.path()).unwrap(); + assert_eq!(reader.archived().map(|d| d.entities.len()).unwrap_or(0), 1); + + let archived = reader.archived().unwrap(); + let entity = &archived.entities[0]; + assert_eq!(entity.handle, doc.entities().next().unwrap().common().handle.value()); + let decoded: EntityType = bincode::deserialize(&entity.data).expect("bincode decode"); + assert!(matches!(decoded, EntityType::Point(_))); + } } diff --git a/crates/ocs_plugin_api/src/type_registry.rs b/crates/ocs_plugin_api/src/type_registry.rs new file mode 100644 index 000000000..9f42487b1 --- /dev/null +++ b/crates/ocs_plugin_api/src/type_registry.rs @@ -0,0 +1,88 @@ +//! Embedded type registry generated at build time. +//! +//! The JSON embedded here is produced by tracing a curated allow-list of +//! `acadrust` types with `serde-reflection` and mapping the result into a +//! stable, language-binding-friendly schema defined in +//! [`crate::type_registry_types`]. + +pub use crate::type_registry_types::*; + +/// The embedded type registry as a JSON string. +pub const EMBEDDED_TYPE_REGISTRY_JSON: &str = + include_str!(concat!(env!("OUT_DIR"), "/type_registry.json")); + +/// Convenience accessor for the embedded type registry JSON. +pub fn get_embedded_type_registry_json() -> &'static str { + EMBEDDED_TYPE_REGISTRY_JSON +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_type_registry_is_valid_json() { + let json = get_embedded_type_registry_json(); + assert!(!json.is_empty()); + let registry: TypeRegistry = serde_json::from_str(json).expect("valid TypeRegistry JSON"); + assert!(!registry.types.is_empty()); + } + + #[test] + fn embedded_type_registry_contains_expected_acadrust_types() { + let registry: TypeRegistry = + serde_json::from_str(get_embedded_type_registry_json()).expect("valid TypeRegistry JSON"); + + for name in ["Point", "Line", "Circle", "EntityCommon"] { + assert!( + registry.types.contains_key(&TypeId::new(name)), + "registry should contain {}", + name + ); + } + } + + #[test] + fn embedded_type_registry_point_is_struct_with_expected_fields() { + let registry: TypeRegistry = + serde_json::from_str(get_embedded_type_registry_json()).expect("valid TypeRegistry JSON"); + + let point = registry + .types + .get(&TypeId::new("Point")) + .expect("Point in registry"); + assert_eq!(point.kind, TypeKind::Struct); + + let field_names: Vec<&str> = point.fields.iter().map(|f| f.name.as_str()).collect(); + for expected in ["common", "location", "thickness", "normal"] { + assert!( + field_names.contains(&expected), + "Point should have field {}", + expected + ); + } + + let common = point + .fields + .iter() + .find(|f| f.name == "common") + .expect("common field"); + assert_eq!(common.type_id.as_str(), "EntityCommon"); + } + + #[test] + fn embedded_type_registry_does_not_contain_entity_type() { + let registry: TypeRegistry = + serde_json::from_str(get_embedded_type_registry_json()).expect("valid TypeRegistry JSON"); + assert!( + !registry.types.contains_key(&TypeId::new("EntityType")), + "EntityType should not be in the allow-list registry" + ); + } + + #[test] + fn type_id_as_str_returns_inner_value() { + let id = TypeId::new("Point"); + assert_eq!(id.as_str(), "Point"); + } +} diff --git a/crates/ocs_plugin_api/src/type_registry_types.rs b/crates/ocs_plugin_api/src/type_registry_types.rs new file mode 100644 index 000000000..76007c69f --- /dev/null +++ b/crates/ocs_plugin_api/src/type_registry_types.rs @@ -0,0 +1,77 @@ +use serde::{Deserialize, Serialize}; + +/// Stable identifier for a type in the embedded registry. +/// +/// `TypeId` serializes as a plain string so it can be used as a JSON object +/// key. The inner value is intentionally private; consumers use `as_str()`. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +pub struct TypeId(String); + +impl TypeId { + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn new(s: impl Into) -> Self { + Self(s.into()) + } +} + +/// Classification of a type in the registry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TypeKind { + Struct, + Enum, + Newtype, + Tuple, + Unit, +} + +/// Description of one field in a struct or enum variant. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldInfo { + pub name: String, + pub type_id: TypeId, + pub optional: bool, + pub is_sequence: bool, +} + +/// Description of one enum variant. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EnumVariantInfo { + pub name: String, + pub discriminant: u32, + pub fields: Vec, +} + +/// Description of one method parameter. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ParameterInfo { + pub name: String, + pub type_id: TypeId, +} + +/// Description of one method on a type. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MethodInfo { + pub name: String, + pub parameters: Vec, + pub return_type: Option, +} + +/// Full description of one type in the registry. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TypeInfo { + pub name: TypeId, + pub kind: TypeKind, + pub fields: Vec, + pub variants: Vec, + pub methods: Vec, + pub doc: Option, +} + +/// Collection of all traced types, keyed by their `TypeId`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct TypeRegistry { + pub types: std::collections::BTreeMap, +} diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs new file mode 100644 index 000000000..1d690c070 --- /dev/null +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -0,0 +1,57 @@ +//! Embedded version metadata generated at build time. + +/// The embedded version info as a JSON string. +pub const EMBEDDED_VERSION_INFO_JSON: &str = + include_str!(concat!(env!("OUT_DIR"), "/version_info.json")); + +/// Convenience accessor for the embedded version info JSON. +pub fn get_embedded_version_info_json() -> &'static str { + EMBEDDED_VERSION_INFO_JSON +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn embedded_version_info_is_valid_json() { + let json = get_embedded_version_info_json(); + assert!(!json.is_empty()); + let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON"); + assert!(value.get("ocs_version").is_some()); + assert!(value.get("acadrust_version").is_some()); + } + + #[test] + fn embedded_version_info_has_expected_keys() { + let value: serde_json::Value = + serde_json::from_str(get_embedded_version_info_json()).expect("valid JSON"); + for key in [ + "ocs_version", + "ocs_plugin_api_version", + "acadrust_version", + "acadrust_source", + "api_version", + "api_version_min_supported", + "build_timestamp", + ] { + assert!(value.get(key).is_some(), "missing version info key {}", key); + } + } + + #[test] + fn embedded_version_info_matches_package_versions() { + let value: serde_json::Value = + serde_json::from_str(get_embedded_version_info_json()).expect("valid JSON"); + assert_eq!( + value["ocs_plugin_api_version"].as_str(), + Some(env!("CARGO_PKG_VERSION")) + ); + assert_eq!(value["api_version"].as_i64(), Some(4)); + assert_eq!(value["api_version_min_supported"].as_i64(), Some(2)); + + // acadrust_version should have a patch component (e.g. "0.4.0"). + let acadrust = value["acadrust_version"].as_str().expect("acadrust_version string"); + assert_eq!(acadrust.split('.').count(), 3); + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index a4d83243f..1eeb867e9 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1692,6 +1692,9 @@ pub enum ArrowKey { #[derive(Debug, Clone)] pub enum Message { Tick(Instant), + /// Periodic drain of plugin-to-host requests that arrived outside a host + /// call (e.g. mutations from the Python REPL). + DrainPluginRequests, /// Web: periodic check for per-script fonts a drawing needs but hasn't /// fetched yet (#141). Native: never emitted. PollWebFonts, diff --git a/src/app/plugin_host.rs b/src/app/plugin_host.rs index 6d37ce439..44cc20758 100644 --- a/src/app/plugin_host.rs +++ b/src/app/plugin_host.rs @@ -4,16 +4,18 @@ use std::any::Any; use acadrust::tables::AppId; use acadrust::xdata::ExtendedDataRecord; -use acadrust::{CadDocument, EntityType, Handle}; -use ocs_plugin_api::host::HostApi; +use ocs_plugin_api::host::{CadDocument, EntityType, Handle, HostApi}; +use ocs_plugin_api::shm::{DocumentSnapshotStore, DocumentViewData}; +#[cfg(not(target_arch = "wasm32"))] +use crate::plugin::v4_support; use super::OpenCADStudio; /// Session adapter: one active document tab, command line, undo. pub(crate) struct HostSession<'a> { app: &'a mut OpenCADStudio, tab: usize, - doc_store: Option, + doc_store: Option>, } impl<'a> HostSession<'a> { @@ -29,6 +31,20 @@ impl<'a> HostSession<'a> { self.tab } + pub fn tab_id(&self) -> u64 { + self.app.tabs[self.tab].id + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn document_view_v4(&mut self, tab_id: u64) -> Option { + v4_support::open_document_view_v4(tab_id, self.document()) + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn close_document_view_v4(&mut self, tab_id: u64) { + v4_support::close_document_view_v4(tab_id); + } + pub fn document(&self) -> &CadDocument { &self.app.tabs[self.tab].scene.document } @@ -38,10 +54,9 @@ impl<'a> HostSession<'a> { } pub fn document_view(&mut self) -> Option { - use ocs_plugin_api::shm::DocumentSnapshotStore; if self.doc_store.is_none() { - let mut store = DocumentSnapshotStore::new(self.tab, 8 * 1024 * 1024).ok()?; - store.publish(self.document()).ok()?; + let mut store = DocumentSnapshotStore::::new(self.tab as u64, 8 * 1024 * 1024).ok()?; + store.publish(&self.document().into()).ok()?; self.doc_store = Some(store); } let store = self.doc_store.as_ref()?; @@ -53,14 +68,18 @@ impl<'a> HostSession<'a> { fn publish_document_view(&mut self) { let doc = &self.app.tabs[self.tab].scene.document; + // Only publish views that have been opened by a consumer. V3 is lazily + // created by document_view(); V4 is tracked per-tab by the V4 manager. if let Some(store) = self.doc_store.as_mut() { - if let Err(e) = store.publish(doc) { + if let Err(e) = store.publish(&doc.into()) { eprintln!( "[host] failed to publish document view for tab {}: {e}", self.tab ); } } + #[cfg(not(target_arch = "wasm32"))] + v4_support::publish_document_view_v4(self.tab_id(), doc); } pub fn add_entity(&mut self, entity: EntityType) -> Handle { @@ -69,6 +88,12 @@ impl<'a> HostSession<'a> { handle } + pub fn add_entities(&mut self, entities: Vec) -> Vec { + let handles = self.app.tabs[self.tab].scene.add_entities(entities); + self.publish_document_view(); + handles + } + pub fn bump_geometry(&mut self) { self.app.tabs[self.tab].scene.bump_geometry(); } @@ -239,6 +264,9 @@ impl HostApi for HostSession<'_> { fn add_entity(&mut self, entity: EntityType) -> Handle { self.add_entity(entity) } + fn add_entities(&mut self, entities: Vec) -> Vec { + self.add_entities(entities) + } fn update_entity(&mut self, entity: EntityType) -> bool { self.update_entity(entity) } @@ -305,6 +333,17 @@ impl HostApi for HostSession<'_> { fn document_view(&mut self) -> Option { self.document_view() } + fn tab_id(&self) -> u64 { + self.tab_id() + } + #[cfg(not(target_arch = "wasm32"))] + fn document_view_v4(&mut self, tab_id: u64) -> Option { + self.document_view_v4(tab_id) + } + #[cfg(not(target_arch = "wasm32"))] + fn close_document_view_v4(&mut self, tab_id: u64) { + self.close_document_view_v4(tab_id) + } } /// Bridges a plugin's [`InteractiveCommand`](ocs_plugin_api::host::InteractiveCommand) @@ -506,6 +545,34 @@ mod tests { ); } + #[test] + fn add_entities_batch_assigns_handles_and_publishes_once() { + let mut app = OpenCADStudio::new_for_test(); + app.tabs[0].is_start = false; + let mut host = HostSession::new(&mut app, 0); + + let pts: Vec = (0..5) + .map(|i| { + EntityType::Point(Point::at(acadrust::types::Vector3::new( + i as f64, i as f64, 0.0, + ))) + }) + .collect(); + let handles = host.add_entities(pts); + + assert_eq!(handles.len(), 5); + assert!(handles.iter().all(|h| !h.is_null())); + // Each handle is unique. + let mut set = std::collections::HashSet::new(); + for h in &handles { + assert!(set.insert(h.value())); + } + // All entities are in the document. + for h in &handles { + assert!(host.document().get_entity(*h).is_some()); + } + } + #[test] fn update_entity_replaces_in_place_preserving_handle() { let mut app = OpenCADStudio::new_for_test(); @@ -704,7 +771,7 @@ mod tests { let mut host = HostSession::new(&mut app, 0); let info = host.document_view().unwrap(); let reader = - ocs_plugin_api::shm::SharedDocumentReader::open(std::path::Path::new(&info.path)) + ocs_plugin_api::shm::SharedDocumentReader::::open(std::path::Path::new(&info.path)) .unwrap(); assert_eq!(reader.entity_count(), 0); @@ -755,7 +822,7 @@ mod tests { let mut host = HostSession::new(&mut app, 0); let info = host.document_view().unwrap(); let reader = - ocs_plugin_api::shm::SharedDocumentReader::open(std::path::Path::new(&info.path)) + ocs_plugin_api::shm::SharedDocumentReader::::open(std::path::Path::new(&info.path)) .unwrap(); let h = host.add_entity(EntityType::Point(Point::at(acadrust::types::Vector3::new( diff --git a/src/app/update/command.rs b/src/app/update/command.rs index 72eec976b..730e41ccf 100644 --- a/src/app/update/command.rs +++ b/src/app/update/command.rs @@ -12,6 +12,8 @@ use crate::modules::ModuleEvent; use crate::scene::pick::grip::{ find_hit_grip, find_hit_grip_paper, find_hit_grip_rte, GripEdit, GripEditMode, }; +#[cfg(not(target_arch = "wasm32"))] +use crate::plugin::v4_support; use crate::scene::model::object::GripApply; use crate::scene::{ self, hover_id, CubeRegion, Scene, VIEWCUBE_DRAW_PX, VIEWCUBE_PAD, VIEWCUBE_PX, @@ -109,6 +111,7 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.pending_close = Some(crate::app::PendingClose::Tab(idx)); return self.open_unsaved_dialog_window(); } + let tab_id = self.tabs.get(idx).map(|t| t.id); // This tab is closing for good — drop its autosave recovery copy. #[cfg(not(target_arch = "wasm32"))] let _ = std::fs::remove_file(self.autosave_target(idx)); @@ -132,6 +135,10 @@ pub(super) fn on_tab_close(&mut self, idx: usize) -> Task { self.sync_ribbon_layers(); self.sync_ribbon_styles(); self.sync_ribbon_from_selection(); + if let Some(tab_id) = tab_id { + #[cfg(not(target_arch = "wasm32"))] + v4_support::on_tab_closed(tab_id); + } Task::none() } diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index eaec47f33..71f1f3933 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -302,6 +302,21 @@ impl OpenCADStudio { fn update_inner(&mut self, msg: Message) -> Task { match msg { + // Drain plugin-to-host requests that arrived outside of a host call. + // This runs on a periodic timer so long-lived plugin sessions such + // as the Python REPL can mutate the document without requiring a + // user-generated message. Desktop only. + #[cfg(not(target_arch = "wasm32"))] + Message::DrainPluginRequests => { + if self.active_tab < self.tabs.len() { + let mut host = crate::app::plugin_host::HostSession::new(self, self.active_tab); + crate::plugin::external::with_manager(|mgr| { + mgr.drain_requests(&mut host, &mut |_| {}); + }); + } + Task::none() + } + // Web: fetch every script queued by startup language selection or // drawing text discovery. Each script has one shared store entry. Message::PollWebFonts => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index cc375fd51..4bc14a895 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -2094,6 +2094,18 @@ impl OpenCADStudio { let single_instance = crate::io::single_instance::subscribe().map(Message::OpenExternal); #[cfg(target_arch = "wasm32")] let single_instance = Subscription::none(); + // Drain plugin-to-host requests on a timer when any plugin is loaded. + // This lets long-lived plugin sessions (e.g. the Python REPL) mutate the + // host document without requiring a user-generated message. + #[cfg(not(target_arch = "wasm32"))] + let plugin_drain = if crate::plugin::external::with_manager(|mgr| !mgr.ids().is_empty()) { + iced::time::every(std::time::Duration::from_millis(100)) + .map(|_| Message::DrainPluginRequests) + } else { + Subscription::none() + }; + #[cfg(target_arch = "wasm32")] + let plugin_drain = Subscription::none(); let hatch_pattern_keys = if self.tabs[self.active_tab] .properties .hatch_pattern_picker_open @@ -2111,6 +2123,7 @@ impl OpenCADStudio { caret_blink, web_fonts, autosave, + plugin_drain, single_instance, hatch_pattern_keys, event::listen_with(|ev, status, win_id| { diff --git a/src/plugin/external.rs b/src/plugin/external.rs index d143d0e2b..c855e7e04 100644 --- a/src/plugin/external.rs +++ b/src/plugin/external.rs @@ -55,7 +55,7 @@ pub struct ExternalPlugin { impl ExternalPlugin { /// True when the package's API version is supported by this host. pub fn api_compatible(&self) -> bool { - self.api_version == ocs_plugin_api::API_VERSION + ocs_plugin_api::manifest::host_accepts_plugin_version(self.api_version) } /// True when the package can be loaded today: compatible API *and* a native @@ -273,6 +273,7 @@ pub(crate) use loader::{load_at_startup, loaded_ids}; #[cfg_attr(test, allow(dead_code))] mod loader { use super::lib_extension; + use crate::plugin::v4_support; use ocs_plugin_api::process::PluginManager; use std::cell::RefCell; use std::path::{Path, PathBuf}; @@ -291,6 +292,7 @@ mod loader { ) -> Vec<(String, Result<(), String>)> { let discovered = super::discover(); let mut manager = PluginManager::new(); + manager.set_notification_handler(v4_support::notification_handler()); let mut out = Vec::new(); for d in &discovered { if !d.api_compatible() || !d.lib_present { @@ -350,12 +352,13 @@ mod loader { } } -#[cfg(test)] +#[cfg(all(test, not(target_arch = "wasm32")))] mod tests { use super::*; + use crate::plugin::v4_support; #[test] - fn api_v2_plugin_from_template_is_incompatible() { + fn api_v2_plugin_from_template_is_compatible() { let toml = r#" [plugin] id = "opencad.my_plugin" @@ -374,7 +377,7 @@ xdata_apps = ["MYPLUGIN_RECORD"] assert_eq!(p.api_version, 2); assert_eq!(p.repository.as_deref(), Some("example/opencad-my-plugin")); assert!(p.command_prefixes.contains(&"MP_".to_string())); - assert!(!p.api_compatible(), "old API plugins must be rebuilt"); + assert!(p.api_compatible(), "V2 plugins must be accepted by the V4 host"); } #[test] @@ -415,7 +418,11 @@ xdata_apps = ["MYPLUGIN_RECORD"] let mut app = crate::app::OpenCADStudio::new_for_test(); let mut host = crate::app::plugin_host::HostSession::new(&mut app, 0); - let process = ocs_plugin_api::process::PluginProcess::spawn(&path, &mut host) + let process = ocs_plugin_api::process::PluginProcess::spawn( + &path, + &mut host, + v4_support::notification_handler(), + ) .expect("spawn test plugin"); assert_eq!(process.id(), "opencad.my_plugin"); let mut started = false; diff --git a/src/plugin/marketplace.rs b/src/plugin/marketplace.rs index f0fb14f54..797580c4f 100644 --- a/src/plugin/marketplace.rs +++ b/src/plugin/marketplace.rs @@ -291,11 +291,12 @@ pub fn install(release: &Release, repository: &str) -> Result { let toml_text = download_string(&toml.url)?; let manifest = external::parse_plugin_toml(&toml_text).ok_or("plugin.toml is missing an id")?; - if manifest.api_version != ocs_plugin_api::API_VERSION { + if !ocs_plugin_api::manifest::host_accepts_plugin_version(manifest.api_version) { return Err(format!( - "API version {} is incompatible (host requires {})", + "API version {} is incompatible (host supports {}-{})", manifest.api_version, - ocs_plugin_api::API_VERSION + ocs_plugin_api::manifest::API_VERSION_MIN_SUPPORTED, + ocs_plugin_api::manifest::effective_max_api_version() )); } diff --git a/src/plugin/mod.rs b/src/plugin/mod.rs index 6e7832983..a1965b8be 100644 --- a/src/plugin/mod.rs +++ b/src/plugin/mod.rs @@ -6,6 +6,8 @@ pub mod external; pub mod host; pub mod marketplace; pub mod registry; +#[cfg(not(target_arch = "wasm32"))] +pub mod v4_support; pub use registry::{all_ribbon_modules, plugin_command_names, ribbon_modules_enabled}; pub(crate) use registry::try_dispatch; diff --git a/src/plugin/v4_support.rs b/src/plugin/v4_support.rs new file mode 100644 index 000000000..23b7d2056 --- /dev/null +++ b/src/plugin/v4_support.rs @@ -0,0 +1,61 @@ +//! Host-side V4 snapshot and notification support for the Python REPL plugin. + +use std::sync::Arc; + +use ocs_plugin_api::host::{HostNotification, PluginNotification}; +use ocs_plugin_api::host_v4::{manager, HostV4SnapshotManager}; +use ocs_plugin_api::process::NotificationHandler; +use ocs_plugin_api::shm::DocumentViewInfo; + +fn mgr() -> std::sync::MutexGuard<'static, HostV4SnapshotManager> { + manager().lock().unwrap() +} + +/// Open (or refresh) the V4 shared document view for `tab_id`. +pub fn open_document_view_v4( + tab_id: u64, + doc: &acadrust::CadDocument, +) -> Option { + mgr().open(tab_id, doc) +} + +/// Close the V4 shared document view for `tab_id`. +pub fn close_document_view_v4(tab_id: u64) { + mgr().close(tab_id); +} + +/// Publish the latest document to the V4 shared view for `tab_id` and broadcast +/// a change notification when successful. +pub fn publish_document_view_v4(tab_id: u64, doc: &acadrust::CadDocument) { + let info = mgr().publish(tab_id, doc); + if let Some(info) = info { + broadcast(HostNotification::DocumentChangedV4 { + tab_id, + version: info.version, + }); + } +} + +/// Notify V4 plugins that a tab has been closed and clean up its snapshot. +pub fn on_tab_closed(tab_id: u64) { + close_document_view_v4(tab_id); + broadcast(HostNotification::DocumentTabClosed { tab_id }); +} + +/// V4 notification handler installed on the plugin manager. +/// +/// Forwards REPL status messages to the log; other notifications are handled +/// by the manager's per-plugin routing. +pub fn notification_handler() -> NotificationHandler { + Arc::new(|plugin_id, _command_id, notification| { + if let PluginNotification::ReplStatus { status, message } = notification { + eprintln!("[{plugin_id}] REPL status {status}: {message}"); + } + }) +} + +fn broadcast(notification: HostNotification) { + crate::plugin::external::with_manager(|mgr| { + mgr.broadcast_notification(None, notification); + }); +} diff --git a/src/scene/entity.rs b/src/scene/entity.rs index 130bd0f80..22bf0a72f 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -111,7 +111,44 @@ impl Scene { let _ = self.document.layers.add(layer); } - pub fn add_entity(&mut self, mut entity: EntityType) -> Handle { + pub fn add_entity(&mut self, entity: EntityType) -> Handle { + self.add_entity_internal(entity, true) + } + + /// Batch-add several entities, publishing geometry changes once at the end. + /// This is the fast path used by plugin `add_entities` requests. + pub fn add_entities(&mut self, entities: Vec) -> Vec { + let mut handles = Vec::with_capacity(entities.len()); + let mut changes = Vec::with_capacity(entities.len()); + let mut needs_geometry_bump = false; + + for entity in entities { + let affects_blocks = matches!( + &entity, + EntityType::Block(_) | EntityType::BlockEnd(_) + ); + let handle = self.add_entity_internal(entity, false); + handles.push(handle); + if handle.is_null() { + continue; + } + if affects_blocks { + needs_geometry_bump = true; + } else { + changes.push((handle, ChangeKind::Added)); + } + } + + if needs_geometry_bump { + self.bump_geometry(); + } else if !changes.is_empty() { + self.bump_entities(&changes); + } + + handles + } + + fn add_entity_internal(&mut self, mut entity: EntityType, bump: bool) -> Handle { // Only block sentinels mutate a block definition and require rebuilding // the block cache. A top-level INSERT merely references an existing // definition, so adding it can patch just that new render handle. @@ -256,12 +293,14 @@ impl Scene { self.poison_undo_recording(); } } - if affects_blocks { - self.bump_geometry(); - } else { - // Plain top-level add: name the new handle so every derived cache - // patches in just this one entity instead of rebuilding. - self.bump_entities(&[(handle, ChangeKind::Added)]); + if bump { + if affects_blocks { + self.bump_geometry(); + } else { + // Plain top-level add: name the new handle so every derived cache + // patches in just this one entity instead of rebuilding. + self.bump_entities(&[(handle, ChangeKind::Added)]); + } } } handle