feat(plugin): emit SelectionChangedV4 to V4 plugins and add ocs_plugin_api architecture docs

- Add HostNotification::SelectionChangedV4 { tab_id, handles } with discriminant 7
- Broadcast active-tab selection changes at update() and automation_op boundaries
- Use stable order-independent signature to avoid spurious hover notifications
- Clear Scene selection in automation new/open to avoid stale handles
- Add crates/ocs_plugin_api/ARCHITECTURE.md and module-level rustdoc
- Cross-link README.md and docs/plugin-architecture.md
- Fix test env race with shared ENV_LOCK and EnvVarGuard helpers
This commit is contained in:
Sebastian 2026-08-25 23:31:27 +02:00
commit f20bf93dfe
25 changed files with 592 additions and 17 deletions

View file

@ -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 <socket> <cdylib>
H->>R: exec
R->>R: create local socket listener
R->>L: unsafe { load(cdylib_path) }
L-->>R: Box<dyn BuiltinPlugin>
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<HostNotification>`.
- `PluginToHostV4`: `Request { id, tab_id, PluginRequest }`, `Response { id, HostResponse }`, `Notification<PluginNotification>`.
- `NotificationEnvelope<T>`: `{ command_id: Option<u64>, 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<Handle>`) once per loaded V4 plugin, and `transport::send` re-serializes the payload into a new `Vec<u8>` per plugin. Fixing this properly requires either a public API change (`Arc<Vec<Handle>>` 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)

View file

@ -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)

View file

@ -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<Handle> },
/// 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)),
}
}

View file

@ -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};

View file

@ -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;

View file

@ -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};

View file

@ -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};

View file

@ -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);

View file

@ -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;

View file

@ -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};

View file

@ -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};

View file

@ -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);

View file

@ -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 <socket> <cdylib>`), 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 {

View file

@ -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;

View file

@ -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]

View file

@ -4,6 +4,18 @@
//! (`--ocs-plugin-runner <socket> <cdylib>`). 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;

View file

@ -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<T>`] — host-side, file-backed double buffer. Call
//! [`DocumentSnapshotStore::publish`] to atomically swap the active segment and
//! increment the version.
//! - [`SharedDocumentReader<T>`] — 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;

View file

@ -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::*;

View file

@ -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;