diff --git a/crates/ocs_plugin_api/ARCHITECTURE.md b/crates/ocs_plugin_api/ARCHITECTURE.md new file mode 100644 index 00000000..19388eb7 --- /dev/null +++ b/crates/ocs_plugin_api/ARCHITECTURE.md @@ -0,0 +1,261 @@ +# `ocs_plugin_api` — Internal Architecture + +Internal-facing architecture documentation for `crates/ocs_plugin_api`. For plugin-author tutorials see [`README.md`](./README.md); for host/plugin integration design goals see [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md). + +--- + +## Overview + +`ocs_plugin_api` is the stable, semver-versioned contract between the Open CAD Studio host and add-on plugins. The crate is intentionally split into two tiers: + +| Tier | Feature | Dependencies | Purpose | +|------|---------|--------------|---------| +| **Core** | default | none | `PluginManifest`, `ApiVersion`, ribbon vocabulary, type registry, version info. Engine crates and external tooling can depend on this cheaply. | +| **Runtime host surface** | `host` | `acadrust`, `interprocess`, `memmap2`, `rkyv`, etc. | `HostApi`, `BuiltinPlugin`, out-of-process spawn, IPC, shared-memory snapshots, runner. | + +The host and each plugin run in separate OS processes. The host re-executes itself in `--ocs-plugin-runner` mode, the runner dynamically loads the plugin `cdylib`, and the two sides talk over a local socket. Process isolation keeps plugin crashes from affecting the host or other plugins. + +--- + +## Module map + +| Module | Responsibility | +|--------|----------------| +| [`src/manifest.rs`](src/manifest.rs) | `API_VERSION`, `API_VERSION_MIN_SUPPORTED`, `ApiVersion` compatibility, runtime `OCS_PLUGIN_MAX_API_VERSION` gate. | +| [`src/ribbon`](src/ribbon) | `CadModule` trait and plain-data ribbon types (`RibbonGroup`, `ToolDef`, `IconKind`, `ModuleEvent`). | +| [`src/host.rs`](src/host.rs) | `HostApi` plugin-facing runtime trait, `BuiltinPlugin` entry-point trait, `export_plugin!` macro, `HostNotification`, `PluginNotification`. | +| [`src/ipc/mod.rs`](src/ipc/mod.rs) | IPC layer public exports. | +| [`src/ipc/protocol.rs`](src/ipc/protocol.rs) | V2/V3 request/response enums (`HostRequest`, `HostResponse`, `PluginRequest`, `PluginResponse`), `RunnerHandshake`, `PLUGIN_TOKEN_ENV`. | +| [`src/ipc/transport.rs`](src/ipc/transport.rs) | Length-framed bincode send/recv over `interprocess::local_socket::Stream`. | +| [`src/ipc/client.rs`](src/ipc/client.rs) | Plugin-side V2/V3 `IpcClient` and `PluginHostApi` proxy. | +| [`src/ipc/server.rs`](src/ipc/server.rs) | Host-side handler that applies a `PluginRequest` to a `HostApi` implementation. | +| [`src/ipc/v4`](src/ipc/v4) | Multiplexed V4 protocol: frames, notifications, correlation ids. | +| [`src/ipc/proxy.rs`](src/ipc/proxy.rs) | Optional TCP request proxy for plugin child processes. | +| [`src/process.rs`](src/process.rs) | `PluginProcess` lifecycle: spawn, handshake, call, timeouts, `NullHost`, io draining. | +| [`src/process/manager.rs`](src/process/manager.rs) | `PluginManager`: owns loaded plugins, dispatch routing, notification broadcast, V4 gating. | +| [`src/process/v4.rs`](src/process/v4.rs) | Host-side V4 connection and reader thread. | +| [`src/runner.rs`](src/runner.rs) | Runner entry point and V2/V3 vs V4 request loops; loads cdylib via `libloading`. | +| [`src/shm.rs`](src/shm.rs) | Shared-memory document snapshot layout, `DocumentSnapshotStore`, `SharedDocumentReader`, V3/V4 view data. | +| [`src/host_v4.rs`](src/host_v4.rs) | Host-side per-tab V4 snapshot manager. | +| [`src/type_registry.rs`](src/type_registry.rs) / [`src/type_registry_types.rs`](src/type_registry_types.rs) | Build-time `serde-reflection` registry, embedded JSON, schema types. | +| [`src/version_info.rs`](src/version_info.rs) | Embedded version metadata and acadrust source-compatibility helper. | + +--- + +## Versioning and ABI stability + +- `API_VERSION` (currently `4`) is the host's advertised major. +- `API_VERSION_MIN_SUPPORTED` (currently `2`) is the oldest plugin major the host loads. +- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `3` to disable V4). +- A plugin built against major `N` runs on a host whose major is `>= N` because new vtable entries and enum variants are appended at the end. +- V4 introduces the **acadrust gate**: plugins targeting API v4 or later must resolve the same `acadrust` source as the host (see [`src/version_info.rs`](src/version_info.rs)). + +The runtime enforces two gates: + +1. `ocs_plugin_api_version()` exported by the cdylib must be within `[API_VERSION_MIN_SUPPORTED, effective_max_api_version()]`. +2. For v4+, `acadrust_sources_compatible(host_acadrust_source(), plugin_acadrust_source)` must be true. + +--- + +## Process model + +```mermaid +sequenceDiagram + participant H as Host process + participant PM as PluginManager + participant PP as PluginProcess + participant R as Runner child + participant L as Plugin cdylib + + PM->>PP: spawn(cdylib_path, host) + PP->>H: spawn --ocs-plugin-runner + H->>R: exec + R->>R: create local socket listener + R->>L: unsafe { load(cdylib_path) } + L-->>R: Box + R->>PP: connect + RunnerHandshake::Token + PP->>PP: verify OCS_PLUGIN_TOKEN + PP->>R: HostToPlugin::Request(GetManifest) + R->>L: manifest() + L-->>R: PluginManifest { api_version } + R-->>PP: HostResponse::Manifest + PP->>PP: verify api_version + acadrust gate + alt accepted + PP-->>PM: success, keep alive + else rejected + PP->>R: Shutdown + PP-->>PM: error + end +``` + +Key invariants: + +- The runner process is the host binary re-executed with special CLI args, so runner and host share the same `ocs_plugin_api` build. +- The plugin cdylib is `dlopen`-ed inside the runner, not the host. +- A pre-shared token (`OCS_PLUGIN_TOKEN`) authenticates the runner to the host. +- `PluginProcess` owns the `Child` handle and a reader thread; `PluginManager` owns the loaded plugins. + +--- + +## Wire protocols + +### V2/V3: request/response over a single socket + +A single bidirectional socket carries both directions. While the host waits for a response to a `HostRequest`, it may receive nested `PluginRequest`s from the runner and handles them inline. + +```mermaid +sequenceDiagram + participant H as Host + participant R as Runner + participant P as BuiltinPlugin + + H->>R: HostToPlugin::Request(Dispatch { cmd: "LINE" }) + R->>P: dispatch(host, "LINE") + P->>R: PluginToHost::Request(AddEntity(...)) + R->>H: HostToPlugin::Request(AddEntity(...)) + H-->>R: PluginToHost::Response(Handle) + R-->>P: PluginResponse::Handle + P-->>R: true + R-->>H: PluginToHost::Response(Bool(true)) +``` + +Compatibility rule: new variants are appended at the end of `HostRequest`, `HostResponse`, `PluginRequest`, and `PluginResponse`, preserving bincode discriminant indices for old plugins. + +### V4: multiplexed frames + +V4 keeps the V3 request vocabulary but multiplexes requests, responses, and best-effort notifications over one local socket. Every request/response carries a correlation `id`; notifications carry an optional `command_id`. + +```mermaid +sequenceDiagram + participant H as Host + participant R as V4 Runner + participant P as BuiltinPlugin + + H->>R: HostToPluginV4::Notification(DocumentChangedV4 { tab_id }) + H->>R: HostToPluginV4::Request { id: 7, ExecuteCode } + R->>P: start_execute_code(host, 7, ...) + P-->>R: true + P->>R: Notification(Output { text: "..." }) + R->>H: PluginToHostV4::Notification(Output) + P->>R: Response { id: 7, result } + R->>H: PluginToHostV4::Response { id: 7, CodeExecutionResult } +``` + +Key types in [`src/ipc/v4/protocol.rs`](src/ipc/v4/protocol.rs): + +- `HostToPluginV4`: `Request { id, HostRequest }`, `Response { id, PluginResponse }`, `Notification`. +- `PluginToHostV4`: `Request { id, tab_id, PluginRequest }`, `Response { id, HostResponse }`, `Notification`. +- `NotificationEnvelope`: `{ command_id: Option, payload: T }`. + +Notifications are best-effort: per-process errors are logged, not propagated. Unknown host-notification discriminants deserialize to `HostNotification::Unknown(raw)` so a newer host does not crash an older plugin. + +--- + +## Shared-memory document views + +V3 and V4 avoid cloning the full `CadDocument` over IPC for large reads. The host owns a memory-mapped file; the plugin maps it read-only. + +```mermaid +sequenceDiagram + participant P as Plugin + participant R as Runner + participant H as Host + participant S as DocumentSnapshotStore + + P->>R: request(OpenDocumentView) + R->>H: PluginRequest::OpenDocumentView + H->>S: DocumentSnapshotStore::new / publish + S-->>H: DocumentViewInfo { path, version } + H-->>R: PluginResponse::DocumentView { path, version } + R-->>P: DocumentViewInfo + P->>S: SharedDocumentReader::open(path) + S-->>P: read-only mapping + loop host publishes new version + H->>S: publish(data) + S->>S: swap active segment, increment version + P->>S: read() + check version + end + P->>P: close reader + H->>S: close(tab_id) +``` + +- [`src/shm.rs`](src/shm.rs) defines the generic double-buffered control page and the `SnapshotData` trait. Implementations live in the same file for V3 (`DocumentViewData`) and V4 (`DocumentViewDataV4`). +- [`src/host_v4.rs`](src/host_v4.rs) keeps a global `HostV4SnapshotManager` keyed by `tab_id`, so each document tab has its own snapshot. +- Segment size defaults to 16 MiB and is configurable via `OCS_V4_SNAPSHOT_SEGMENT_SIZE` (minimum 1 MiB). + +--- + +## Notifications + +- **Host → plugin:** `HostNotification` in [`src/host.rs`](src/host.rs). V4-only delivery via `PluginManager::broadcast_notification`. Currently emitted examples: `DocumentChangedV4`, `SelectionChangedV4`, `DocumentTabClosed`. +- **Plugin → host:** `PluginNotification` in [`src/host.rs`](src/host.rs). Examples: `Output`, `Progress`, `Log`. Handler installed via `PluginManager::set_notification_handler`. + +Broadcasting is V4-gated inside `PluginManager::broadcast_notification`: V2/V3 processes are skipped. The handler runs on the V4 reader thread and must not block. + +--- + +## Error handling and timeouts + +| Kind | Default | Env var | Behavior | +|------|---------|---------|----------| +| Spawn / connect timeout | 30 s | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | Host aborts spawn, plugin is not loaded. | +| Per-call timeout | 30 s | `OCS_PLUGIN_CALL_TIMEOUT_SECS` | Host kills runner, plugin marked dead. Floor timeouts apply to `GetManifest`/`GetRibbon`, `Dispatch`, and interactive events. | +| Oversized message | — | — | Transport rejects messages > 64 MiB with `TransportError::TooLarge`. | +| Malformed message | — | — | `bincode` deserialize error; V4 reader logs and continues if possible. | +| Panic in plugin code | — | — | Caught by `catch_unwind` in the runner; converted to `HostResponse::Error`. | +| Dead runner | — | — | Detected via `try_wait` on next dispatch or ribbon rebuild; plugin dropped. | + +--- + +## Embedded metadata + +The crate embeds two JSON blobs at build time: + +- **Type registry** (`OUT_DIR/type_registry.json`) — a language-binding-friendly schema for a curated allow-list of `acadrust` types, generated by `serde-reflection` in `build.rs`. See [`src/type_registry.rs`](src/type_registry.rs). +- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs). + +Both are accessible without enabling the `host` feature. + +--- + +## Security / isolation boundaries + +- **Process isolation:** plugin code runs in a child process. +- **Token handshake:** `OCS_PLUGIN_TOKEN` is generated per spawn and checked before accepting the runner connection. +- **C-ABI export:** plugins expose only two symbols, `ocs_plugin_api_version` and `ocs_plugin_register`, defined by `export_plugin!`. +- **Message size cap:** 64 MiB per length-framed message. +- **Shared memory:** plugin maps snapshots read-only; the host controls publish/close. + +This is defense-in-depth, not a sandbox: plugins execute native code. + +--- + +## Failure modes + +| Scenario | Result | +|----------|--------| +| Plugin panics during dispatch | Runner catches, returns error response, process stays alive. | +| Call timeout | Host kills runner, plugin marked dead. | +| Spawn timeout or crash | Spawn fails; plugin not loaded; error surfaced in UI/logs. | +| Version mismatch | Host refuses plugin before running plugin code. | +| Acadrust source mismatch (v4+) | Host refuses plugin to avoid binary incompatibilities. | +| Unknown notification discriminant | Deserializes to `HostNotification::Unknown`; plugin can ignore. | + +--- + +## Open tasks + +- **Reduce broadcast notification clone/serialize overhead.** `PluginManager::broadcast_notification` clones the `HostNotification` (including its `Vec`) once per loaded V4 plugin, and `transport::send` re-serializes the payload into a new `Vec` per plugin. Fixing this properly requires either a public API change (`Arc>` in `HostNotification`) or a new bytes-oriented broadcast path in the IPC layer. It only matters with many plugins plus very large selections. + +--- + +## Cross-references + +- Plugin-author quick start: [`README.md`](./README.md) +- Host/plugin integration spec: [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md) +- Plugin template: [`../../docs/plugin-template/`](../../docs/plugin-template/) +- Process spawn: [`src/process.rs`](src/process.rs) +- Process manager: [`src/process/manager.rs`](src/process/manager.rs) +- Runner loop: [`src/runner.rs`](src/runner.rs) +- V4 protocol: [`src/ipc/v4/protocol.rs`](src/ipc/v4/protocol.rs) +- Shared memory: [`src/shm.rs`](src/shm.rs) diff --git a/crates/ocs_plugin_api/README.md b/crates/ocs_plugin_api/README.md index 93951816..25021c8e 100644 --- a/crates/ocs_plugin_api/README.md +++ b/crates/ocs_plugin_api/README.md @@ -120,9 +120,10 @@ The frame format supports: - 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. +`HostNotification::DocumentChangedV4`, `SelectionChangedV4`, or +`DocumentTabClosed` 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 @@ -420,7 +421,8 @@ std::thread::spawn(move || { ### Further reading +- Internal architecture & invariants: [`ARCHITECTURE.md`](./ARCHITECTURE.md) +- Host/plugin integration spec: [`../../docs/plugin-architecture.md`](../../docs/plugin-architecture.md) - 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/src/host.rs b/crates/ocs_plugin_api/src/host.rs index eb2220b9..f146c23a 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -69,6 +69,8 @@ pub enum HostNotification { DocumentChangedV4 { tab_id: u64, version: u64 }, /// V4 tab closed notification. Discriminant 6. DocumentTabClosed { tab_id: u64 }, + /// V4 selection changed for a specific tab. Discriminant 7. + SelectionChangedV4 { tab_id: u64, handles: Vec }, /// Fallback for notification variants added in future minor revisions. /// Carries the raw bincode payload so an older peer can ignore it without /// failing deserialization. @@ -112,6 +114,13 @@ impl Serialize for HostNotification { bincode::serialize_into(&mut bytes, tab_id) .map_err(serde::ser::Error::custom)?; } + HostNotification::SelectionChangedV4 { tab_id, handles } => { + bytes.push(7); + bincode::serialize_into(&mut bytes, tab_id) + .map_err(serde::ser::Error::custom)?; + bincode::serialize_into(&mut bytes, handles) + .map_err(serde::ser::Error::custom)?; + } HostNotification::Unknown(raw) => bytes.extend_from_slice(raw), } bytes.serialize(serializer) @@ -146,6 +155,9 @@ impl<'de> Deserialize<'de> for HostNotification { 6 => bincode::deserialize(rest) .map(|tab_id| HostNotification::DocumentTabClosed { tab_id }) .map_err(serde::de::Error::custom), + 7 => bincode::deserialize(rest) + .map(|(tab_id, handles)| HostNotification::SelectionChangedV4 { tab_id, handles }) + .map_err(serde::de::Error::custom), _ => Ok(HostNotification::Unknown(bytes)), } } diff --git a/crates/ocs_plugin_api/src/host_v4.rs b/crates/ocs_plugin_api/src/host_v4.rs index c6341338..3674ea8f 100644 --- a/crates/ocs_plugin_api/src/host_v4.rs +++ b/crates/ocs_plugin_api/src/host_v4.rs @@ -3,6 +3,12 @@ //! 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. +//! +//! [`manager`] returns a global `Mutex`-guarded [`HostV4SnapshotManager`]. +//! [`HostV4SnapshotManager::open`] lazily creates a [`DocumentSnapshotStore`] for +//! a tab; [`HostV4SnapshotManager::publish`] updates an existing store; +//! [`HostV4SnapshotManager::close`] drops it. Segment size defaults to 16 MiB +//! and can be overridden with `OCS_V4_SNAPSHOT_SEGMENT_SIZE`. use std::collections::HashMap; use std::sync::{Mutex, OnceLock}; diff --git a/crates/ocs_plugin_api/src/ipc/mod.rs b/crates/ocs_plugin_api/src/ipc/mod.rs index 5ea05e15..44e7e02e 100644 --- a/crates/ocs_plugin_api/src/ipc/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/mod.rs @@ -2,6 +2,21 @@ //! //! Built only with the `host` feature because it needs `acadrust`-typed //! messages and the plugin runner binary. +//! +//! Submodules: +//! +//! - [`protocol`](crate::ipc::protocol) — V2/V3 request/response enums and the +//! initial runner handshake. +//! - [`transport`](crate::ipc::transport) — length-framed bincode over a local +//! socket; enforces `MAX_MESSAGE_SIZE`. +//! - [`client`](crate::ipc::client) — plugin-side V2/V3 client and `HostApi` +//! proxy. +//! - [`server`](crate::ipc::server) — host-side handler that applies a +//! `PluginRequest` to a `HostApi` implementation. +//! - [`v4`](crate::ipc::v4) — multiplexed V4 frames, notifications, and +//! correlation ids. +//! - [`proxy`](crate::ipc::proxy) — optional TCP proxy so plugin child +//! processes can forward requests to the host. #[cfg(feature = "host")] pub mod client; diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index c8735a30..5b494026 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -4,6 +4,16 @@ //! (expecting a response) or a response (to a previous request). This lets the //! host handle plugin RPCs inline while it waits for the result of a host→plugin //! request such as `Dispatch`, avoiding the need for two sockets or threads. +//! +//! Compatibility rule: new variants are appended at the end of every public enum +//! (`HostRequest`, `HostResponse`, `PluginRequest`, `PluginResponse`, +//! `RunnerHandshake`) so older plugins keep their bincode discriminant indices. +//! The V4 additions are a separate frame layer in [`crate::ipc::v4`] and do not +//! alter these enums. +//! +//! The pre-shared runner token is delivered through [`PLUGIN_TOKEN_ENV`] +//! (`OCS_PLUGIN_TOKEN`). The runner must present the same token immediately +//! after connecting or the host closes the connection. use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_plugin_api/src/ipc/transport.rs b/crates/ocs_plugin_api/src/ipc/transport.rs index 69e42ce6..3cc1296d 100644 --- a/crates/ocs_plugin_api/src/ipc/transport.rs +++ b/crates/ocs_plugin_api/src/ipc/transport.rs @@ -1,4 +1,12 @@ //! Length-framed transport over `interprocess::local_socket` streams. +//! +//! Messages are serialized with `bincode`, prefixed by a little-endian `u64` +//! length, and sent over the stream. The receiver parses the length, bounds it +//! against [`MAX_MESSAGE_SIZE`], then deserializes the payload. +//! +//! [`send`] and [`recv`] are synchronous and block until the full frame is read +//! or the peer disconnects. `Disconnected` is returned on clean EOF; `TooLarge` +//! rejects messages that exceed 64 MiB to protect host/runner memory. use std::io::{Read, Write}; diff --git a/crates/ocs_plugin_api/src/ipc/v4/client.rs b/crates/ocs_plugin_api/src/ipc/v4/client.rs index 6eda5605..a491a6eb 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/client.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/client.rs @@ -761,7 +761,6 @@ impl DocumentReader for EmptyDocumentReader { #[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::{ @@ -772,10 +771,9 @@ mod tests { use super::*; use crate::ipc::transport::recv; use crate::ipc::v4::protocol::{HostToPluginV4, PluginToHostV4}; + use crate::test_lock::ENV_LOCK; 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); diff --git a/crates/ocs_plugin_api/src/ipc/v4/mod.rs b/crates/ocs_plugin_api/src/ipc/v4/mod.rs index e67788bd..3a27ac7e 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/mod.rs @@ -4,6 +4,15 @@ //! 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. +//! +//! Submodules: +//! +//! - [`protocol`](crate::ipc::v4::protocol) — V4 wire frames, notification +//! envelopes, and correlation ids. +//! - [`client`](crate::ipc::v4::client) — plugin-side V4 client loop and +//! notification dispatch. +//! - [`server`](crate::ipc::v4::server) — host-side V4 connection and reader +//! thread. pub mod client; pub mod protocol; diff --git a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs index 2d339b5d..34e97c2e 100644 --- a/crates/ocs_plugin_api/src/ipc/v4/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/v4/protocol.rs @@ -1,4 +1,23 @@ //! V4 wire frames and envelope types. +//! +//! V4 replaces the simple request/response pipe with a multiplexed frame layer. +//! Every request and response carries a monotonically increasing correlation +//! `id` so a plugin can send/receive out of order. Notifications carry an +//! optional `command_id` so a plugin can associate a host notification with the +//! long-running command that caused it. +//! +//! The two frame enums are: +//! +//! - [`HostToPluginV4`] — host → runner requests, responses to runner requests, +//! and host notifications. +//! - [`PluginToHostV4`] — runner → host requests, responses to host requests, +//! and plugin notifications. +//! +//! Notifications are wrapped in [`NotificationEnvelope`] and are best-effort: +//! a process that fails to accept a notification is logged and skipped, but the +//! connection stays alive. Unknown host-notification discriminants deserialize +//! to [`crate::host::HostNotification::Unknown`] so newer host notifications do +//! not break older plugins. use serde::{Deserialize, Serialize}; diff --git a/crates/ocs_plugin_api/src/lib.rs b/crates/ocs_plugin_api/src/lib.rs index e721983e..9b430922 100644 --- a/crates/ocs_plugin_api/src/lib.rs +++ b/crates/ocs_plugin_api/src/lib.rs @@ -20,6 +20,17 @@ //! 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. +//! +//! For internal architecture (process model, wire protocols, versioning policy, +//! failure modes), see `ARCHITECTURE.md` in the crate root. The modules enabled +//! by the `host` feature are: +//! +//! - `host` — plugin/runtime traits and notification types. +//! - `host_v4` — per-tab shared-memory snapshot manager. +//! - `ipc` — transport, V2/V3 protocol, and V4 multiplexed protocol. +//! - `process` — `PluginProcess` and `PluginManager`. +//! - `runner` — child-process runner entry point. +//! - `shm` — shared-memory document views. pub mod manifest; pub mod ribbon; @@ -64,5 +75,15 @@ pub use type_registry::{ }; pub use version_info::get_embedded_version_info_json; +#[cfg(test)] +pub(crate) mod test_lock { + //! Shared lock for tests that mutate process environment variables. + //! + //! Environment variables are global mutable state. Any test that sets or + //! removes an env var must hold this lock for the duration of the mutation + //! so tests in other modules do not observe half-written state. + pub static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); +} + #[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 0d459341..39352e8a 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -1,4 +1,16 @@ -//! Plugin identity and capability declaration. +//! Plugin identity, API version constants, and compatibility rules. +//! +//! This module defines the host ABI major version (`API_VERSION`), the oldest +//! major the host still loads (`API_VERSION_MIN_SUPPORTED`), and the runtime +//! `OCS_PLUGIN_MAX_API_VERSION` gate that lets operators disable newer API +//! majors without rebuilding. +//! +//! Compatibility rule: a plugin compiled against major `N` runs on any host +//! whose major is `>= N`, because new vtable entries and enum variants are +//! appended at the end. `host_accepts_plugin_version` enforces the supported +//! range after applying the runtime cap. +//! +//! See `ARCHITECTURE.md` in the crate root for the full versioning policy. /// Host plugin API version. Bump when the host runtime surface breaks /// compatibility. v2 added `HostApi::start_interactive`. v3 changes @@ -56,12 +68,10 @@ pub fn host_accepts_plugin_version(plugin_major: u32) -> bool { #[cfg(test)] mod tests { - use std::sync::Mutex; + use crate::test_lock::ENV_LOCK; use super::*; - static ENV_LOCK: Mutex<()> = Mutex::new(()); - #[test] fn current_matches_const() { assert_eq!(ApiVersion::CURRENT.major, API_VERSION); diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 9a26d958..7535af35 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -1,4 +1,22 @@ //! Process management for out-of-process plugins. +//! +//! This module defines the lifecycle of a single plugin process: +//! +//! 1. `PluginProcess::spawn` creates a local socket, spawns the host binary in +//! runner mode (`--ocs-plugin-runner `), and waits for the +//! runner to connect back. +//! 2. The runner presents a pre-shared token via [`crate::ipc::protocol::PLUGIN_TOKEN_ENV`]; +//! the host rejects the connection on mismatch. +//! 3. The host requests the manifest, checks `api_version` and (for v4+) the +//! acadrust source gate, then keeps the process alive. +//! 4. Host → plugin calls (`dispatch`, `execute_code`, interactive events) are +//! sent over the socket with a configurable per-call timeout. +//! 5. Stdout/stderr of the child are drained into `PluginIoLine` records for +//! logging and UI display. +//! +//! `NullHost` is a dummy `HostApi` implementation used for V4 paths that do not +//! supply a real host surface (e.g., interactive events). Timeouts and message +//! size limits are centralized here. use std::path::{Path, PathBuf}; use std::process::{Child, Command, Stdio}; @@ -1119,6 +1137,7 @@ mod timeout_tests { }; use crate::ipc::transport::{recv, send}; use crate::ribbon::owned::OwnedPluginManifest; + use crate::test_lock::ENV_LOCK; use acadrust::xdata::ExtendedDataRecord; use acadrust::{CadDocument, EntityType, Handle}; use interprocess::local_socket::{ @@ -1130,8 +1149,6 @@ mod timeout_tests { use std::thread; use std::time::Instant; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); - struct EmptyReader; impl DocumentReader for EmptyReader { diff --git a/crates/ocs_plugin_api/src/process/manager.rs b/crates/ocs_plugin_api/src/process/manager.rs index d2fec1e4..003f6ff0 100644 --- a/crates/ocs_plugin_api/src/process/manager.rs +++ b/crates/ocs_plugin_api/src/process/manager.rs @@ -1,4 +1,19 @@ //! Process manager for out-of-process plugins. +//! +//! `PluginManager` is the host's owner of every loaded plugin process. It is +//! responsible for: +//! +//! - Spawning plugins via [`PluginProcess::spawn`] and building their ribbon +//! modules. +//! - Routing commands to plugins through [`PluginManager::dispatch`]. +//! - Broadcasting host notifications to every alive V4 plugin via +//! [`PluginManager::broadcast_notification`]. +//! - Surfacing dead plugins and per-plugin errors in [`DispatchResult`]. +//! +//! Notifications are V4-only: `broadcast_notification` skips non-V4 processes. +//! The plugin-to-host notification handler (set via +//! [`PluginManager::set_notification_handler`]) runs on the V4 reader thread +//! and must not block. For heavy work, forward the notification to a channel. use std::path::Path; use std::sync::Arc; diff --git a/crates/ocs_plugin_api/src/process/v4.rs b/crates/ocs_plugin_api/src/process/v4.rs index 9843c3bb..d2fc6015 100644 --- a/crates/ocs_plugin_api/src/process/v4.rs +++ b/crates/ocs_plugin_api/src/process/v4.rs @@ -517,7 +517,7 @@ mod tests { use std::sync::Mutex as StdMutex; - static ENV_LOCK: StdMutex<()> = StdMutex::new(()); + use crate::test_lock::ENV_LOCK; fn set_test_env() { std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", "2"); @@ -532,6 +532,13 @@ mod tests { #[test] fn execute_code_timeout_floor_is_at_least_60s_and_env_override() { let _guard = ENV_LOCK.lock().unwrap(); + let prev_call_timeout = std::env::var("OCS_PLUGIN_CALL_TIMEOUT_SECS").ok(); + let prev_test_floor = std::env::var("OCS_PLUGIN_TEST_FLOOR_SECS").ok(); + let prev_execute_timeout = std::env::var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS").ok(); + // Remove the test floor bypass and the call-timeout override so the + // ExecuteCode-specific floor (60 s) is actually exercised. + std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"); + std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS"); std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"); assert!( request_timeout("ExecuteCode") >= Duration::from_secs(60), @@ -544,7 +551,19 @@ mod tests { Duration::from_secs(120), "env override should be respected" ); - std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"); + + match prev_call_timeout { + Some(v) => std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_CALL_TIMEOUT_SECS"), + } + match prev_test_floor { + Some(v) => std::env::set_var("OCS_PLUGIN_TEST_FLOOR_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_TEST_FLOOR_SECS"), + } + match prev_execute_timeout { + Some(v) => std::env::set_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS", v), + None => std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS"), + } } #[test] diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index 6b4a60c6..03f0097f 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -4,6 +4,18 @@ //! (`--ocs-plugin-runner `). Keeping the runner code inside //! `ocs_plugin_api` means the host only needs to know the CLI contract, not the //! internal plugin-loading and IPC details. +//! +//! Entry points and loops: +//! +//! - [`run`] — loads the cdylib with `libloading`, connects back to the host, +//! and dispatches to either the V2/V3 loop or the V4 loop based on the plugin's +//! declared API version. +//! - `run_v3` — synchronous request/response loop over [`crate::ipc::client::IpcClient`]. +//! - `run_v4` — multiplexed frame loop over [`crate::ipc::v4::client::V4Client`]; +//! drains notifications and dispatches host requests. +//! +//! Panics inside plugin callbacks are caught by `catch_unwind` and converted to +//! error responses so a buggy plugin callback does not tear down the runner. use std::cell::RefCell; use std::collections::HashMap; diff --git a/crates/ocs_plugin_api/src/shm.rs b/crates/ocs_plugin_api/src/shm.rs index ebada4e5..4203ce40 100644 --- a/crates/ocs_plugin_api/src/shm.rs +++ b/crates/ocs_plugin_api/src/shm.rs @@ -8,6 +8,21 @@ //! 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. +//! +//! Key types: +//! +//! - [`DocumentSnapshotStore`] — host-side, file-backed double buffer. Call +//! [`DocumentSnapshotStore::publish`] to atomically swap the active segment and +//! increment the version. +//! - [`SharedDocumentReader`] — plugin-side read-only mapping. +//! - [`DocumentViewInfo`] — path + version returned to the plugin so it can +//! open the mapping. +//! - [`DocumentViewData`] / [`DocumentViewDataV4`] — concrete snapshot payloads +//! for V3 and V4. +//! +//! The control page at the start of the mapping stores magic, version, active +//! segment, and length. The two snapshot segments follow it. Publishing writes +//! to the inactive segment, then flips the active segment atomically. use std::fs::OpenOptions; use std::io; diff --git a/crates/ocs_plugin_api/src/type_registry.rs b/crates/ocs_plugin_api/src/type_registry.rs index a563d715..ce55abce 100644 --- a/crates/ocs_plugin_api/src/type_registry.rs +++ b/crates/ocs_plugin_api/src/type_registry.rs @@ -3,7 +3,15 @@ //! 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`]. +//! [`crate::type_registry_types`]. The registry is embedded via +//! `include_str!(concat!(env!("OUT_DIR"), "/type_registry.json"))` so the +//! `host` feature is not required to read it. +//! +//! The allow-list is intentionally narrow: it excludes generic containers and +//! internal enums such as `EntityType` in favor of concrete serializable structs +//! like `Point`, `Line`, `Circle`, and `MText`. Language bindings and tooling +//! can depend on this schema without pulling the full `acadrust` dependency +//! tree. pub use crate::type_registry_types::*; diff --git a/crates/ocs_plugin_api/src/version_info.rs b/crates/ocs_plugin_api/src/version_info.rs index 1df0bc30..35c870cd 100644 --- a/crates/ocs_plugin_api/src/version_info.rs +++ b/crates/ocs_plugin_api/src/version_info.rs @@ -1,4 +1,16 @@ //! Embedded version metadata generated at build time. +//! +//! At compile time `build.rs` writes a small JSON blob to +//! `OUT_DIR/version_info.json` containing host version, `ocs_plugin_api` +//! version, `acadrust` version and source, API version bounds, and a build +//! timestamp. The blob is embedded via `include_str!` and is available without +//! enabling the `host` feature. +//! +//! The acadrust source string is used to detect binary-incompatible plugin +//! builds. For API v4 and later the host compares the plugin's resolved +//! `acadrust` source with its own via [`acadrust_sources_compatible`]. Two +//! sources are considered compatible only when they resolve to the same 40 +//! character git commit hash. use std::sync::OnceLock; diff --git a/docs/plugin-architecture.md b/docs/plugin-architecture.md index b3a5e5a4..dc1a8d0a 100644 --- a/docs/plugin-architecture.md +++ b/docs/plugin-architecture.md @@ -127,6 +127,7 @@ concrete types: | Command line | `push_info`, `push_output`, `push_error` | | Undo / dirty | `push_undo`, `set_dirty` | | Tab | `tab_index()` | +| Notifications (V4) | `on_notification` receives `HostNotification::SelectionChangedV4 { tab_id, handles }` when the active tab's selection changes, plus `DocumentChangedV4` / `DocumentTabClosed`. | ### `export_plugin!` — the C-ABI export @@ -309,7 +310,7 @@ Two timeouts protect the host from a stuck runner: | Timeout | Env var | Default | Floor | |---|---|---|---| -| Spawn (connection) | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | 10 s | — | +| Spawn (connection) | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | 30 s | — | | Per-call | `OCS_PLUGIN_CALL_TIMEOUT_SECS` | 30 s | `GetManifest`/`GetRibbon` ≥ 5 s, `Dispatch` ≥ 10 s, interactive events/prompt/pick ≥ 2 s | A call timeout covers the full round-trip, including any nested plugin→host @@ -402,6 +403,7 @@ Next: | Piece | Location | |-------|----------| | Contract crate + runtime | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) | +| Internal API architecture | [`crates/ocs_plugin_api/ARCHITECTURE.md`](../crates/ocs_plugin_api/ARCHITECTURE.md) | | Plugin runner implementation | [`crates/ocs_plugin_api/src/runner.rs`](../crates/ocs_plugin_api/src/runner.rs) | | Host spawn logic | [`crates/ocs_plugin_api/src/process.rs`](../crates/ocs_plugin_api/src/process.rs) | | Host plugin integration | `src/plugin/`, `src/app/plugin_host.rs` | diff --git a/src/app/automation.rs b/src/app/automation.rs index f72c3048..fddac688 100644 --- a/src/app/automation.rs +++ b/src/app/automation.rs @@ -193,6 +193,17 @@ fn entity_json(e: &acadrust::EntityType) -> Value { impl OpenCADStudio { /// Handle one JSON request line and return the JSON response. pub(crate) fn automation_op(&mut self, line: &str) -> Value { + let res = self.automation_op_inner(line); + // Most automation ops mutate `Scene::selected` directly rather than + // going through `update()` (`select` calls `deselect_all` / + // `select_entity`, and `run` can erase the selected entities), so the + // selection check has to run on this path too. + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_selection_changed(); + res + } + + fn automation_op_inner(&mut self, line: &str) -> Value { let req: Value = match serde_json::from_str(line) { Ok(v) => v, Err(e) => return err(format!("invalid JSON: {e}")), @@ -201,6 +212,7 @@ impl OpenCADStudio { "new" => { let i = self.active_tab; self.tabs[i].scene.document = acadrust::CadDocument::new(); + self.tabs[i].scene.deselect_all(); self.tabs[i].current_path = None; // The headless session starts on the welcome (Start) tab, which // blocks drawing commands; turn it into a real drawing. @@ -224,6 +236,7 @@ impl OpenCADStudio { Ok(doc) => { let i = self.active_tab; self.tabs[i].scene.document = doc; + self.tabs[i].scene.deselect_all(); crate::app::style_ops::ensure_standard_styles( &mut self.tabs[i].scene.document, ); diff --git a/src/app/mod.rs b/src/app/mod.rs index 3830c21d..a82dc50e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -679,6 +679,10 @@ pub(super) struct OpenCADStudio { /// keep their manifest listed but drop their ribbon tab and command /// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`]. disabled_plugins: rustc_hash::FxHashSet, + /// `(tab id, selection signature)` last broadcast to V4 plugins, so + /// `SelectionChangedV4` fires once per real change rather than per message. + #[cfg(not(target_arch = "wasm32"))] + last_plugin_selection: Option<(u64, u64)>, /// External add-on packages found in the plugins folder, refreshed when the /// Plugin Manager opens. external_plugins: Vec, @@ -3222,6 +3226,8 @@ impl OpenCADStudio { attr_editor_tab: crate::ui::window::attribute_editor::AttrTab::Attribute, attr_editor_selected: 0, disabled_plugins: rustc_hash::FxHashSet::default(), + #[cfg(not(target_arch = "wasm32"))] + last_plugin_selection: None, external_plugins: Vec::new(), loaded_plugin_ids: rustc_hash::FxHashSet::default(), plugin_load_errors: rustc_hash::FxHashMap::default(), diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 6fbd26db..2a947376 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -231,6 +231,31 @@ impl OpenCADStudio { } } + /// Emit `SelectionChangedV4` to V4 plugins when the active tab's selection + /// set actually changed since the last broadcast. + /// + /// Checked at the message boundary rather than at the mutation sites so + /// every path is covered: picking, window select, QSELECT, SELECTALL, + /// grip edits that drop the selection, and the automation `select` op. + /// The signature comparison keeps hover and repeated no-op selects from + /// producing spurious notifications. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn notify_plugins_selection_changed(&mut self) { + if self.active_tab >= self.tabs.len() { + return; + } + let i = self.active_tab; + let tab_id = self.tabs[i].id; + let sig = self.tabs[i].scene.selection_sig(); + let key = (tab_id, sig); + if self.last_plugin_selection == Some(key) { + return; + } + self.last_plugin_selection = Some(key); + let handles = self.tabs[i].scene.selected_handles_in_order(); + crate::plugin::v4_support::publish_selection_changed_v4(tab_id, handles); + } + pub fn update(&mut self, msg: Message) -> Task { let perf_started = crate::perf::enabled().then(Instant::now); let perf_label = perf_message_label(&msg); @@ -275,6 +300,11 @@ impl OpenCADStudio { // The block panel watches the drawing's block list and rebuilds its // thumbnails whenever the names change (BLOCK define, file open, …). self.refresh_block_palette_if_stale(); + // Let V4 plugins observe selection changes that happened while handling + // this message (picking, window select, QSELECT, SELECTALL, grip edits, + // and plugin request draining). + #[cfg(not(target_arch = "wasm32"))] + self.notify_plugins_selection_changed(); // OTRACK acquires tracking points only while a command or grip drag is // running; drop them once neither is active so the temporary tracking // points / vectors disappear when the command ends (issue #64). diff --git a/src/plugin/v4_support.rs b/src/plugin/v4_support.rs index 23b7d205..59259f0f 100644 --- a/src/plugin/v4_support.rs +++ b/src/plugin/v4_support.rs @@ -42,6 +42,15 @@ pub fn on_tab_closed(tab_id: u64) { broadcast(HostNotification::DocumentTabClosed { tab_id }); } +/// Broadcast a selection change to V4 plugins. +/// +/// `HostNotification::SelectionChangedV4` carries the active tab id and the +/// current selection set. The caller is responsible for only calling this when +/// the selection actually changed; see `OpenCADStudio::notify_plugins_selection_changed`. +pub fn publish_selection_changed_v4(tab_id: u64, handles: Vec) { + broadcast(HostNotification::SelectionChangedV4 { tab_id, handles }); +} + /// V4 notification handler installed on the plugin manager. /// /// Forwards REPL status messages to the log; other notifications are handled diff --git a/src/scene/selection.rs b/src/scene/selection.rs index af107d9c..195e071e 100644 --- a/src/scene/selection.rs +++ b/src/scene/selection.rs @@ -1,6 +1,9 @@ // Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged. use super::*; +use std::collections::hash_map::DefaultHasher; +use std::hash::{Hash, Hasher}; + impl Scene { // ── Selection ───────────────────────────────────────────────────────── /// Treat a classic LEADER and its attached annotation as one logical object. @@ -57,6 +60,21 @@ impl Scene { self.bump_selection(); } + /// Order-independent signature of the current selection set. + /// + /// `selection_generation` cannot be used for change detection because it + /// also bumps on hover. This signature combines a per-handle hash with XOR + /// so it is allocation-free, O(n), and insensitive to selection order. + pub(crate) fn selection_sig(&self) -> u64 { + let mut combined = 0u64; + for handle in &self.selected { + let mut hasher = DefaultHasher::new(); + handle.hash(&mut hasher); + combined ^= hasher.finish(); + } + combined + } + pub(crate) fn selected_handles_in_order(&self) -> Vec { let mut seen = HashSet::default(); let mut ordered: Vec<_> = self @@ -838,3 +856,31 @@ impl Scene { restored } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn selection_sig_is_order_independent() { + let mut scene_a = Scene::default(); + let mut scene_b = Scene::default(); + let h1 = Handle::new(1); + let h2 = Handle::new(2); + scene_a.select_entity(h1, false); + scene_a.select_entity(h2, false); + scene_b.select_entity(h2, false); + scene_b.select_entity(h1, false); + assert_eq!(scene_a.selection_sig(), scene_b.selection_sig()); + } + + #[test] + fn selection_sig_changes_when_selection_changes() { + let mut scene = Scene::default(); + let empty_sig = scene.selection_sig(); + scene.select_entity(Handle::new(1), false); + assert_ne!(scene.selection_sig(), empty_sig); + scene.deselect_all(); + assert_eq!(scene.selection_sig(), empty_sig); + } +}