fix(tolerance): complete workflow integration

Sync current main and companion codec changes before preserving unsupported frame rows, rendering in the entity plane, and sharing final layout metrics with placement preview.
This commit is contained in:
Hakan Seven 2026-08-27 00:14:08 +03:00 committed by GitHub
commit 81e7add1dd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 3703 additions and 929 deletions

View file

@ -0,0 +1,264 @@
# `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 PM as PluginManager (host)
participant PP as PluginProcess (host)
participant R as Runner child
participant L as Plugin cdylib
PM->>PP: spawn(cdylib_path, host)
PP->>PP: create local socket listener
PP->>R: spawn --ocs-plugin-runner <socket> <cdylib>
R->>L: unsafe { load(cdylib_path) }
L-->>R: Box<dyn BuiltinPlugin>
alt API v4
R->>PP: connect + RunnerHandshake::TokenV4
PP->>PP: verify token + V4 protocol/API gate
else API v2/v3
R->>PP: connect + RunnerHandshake::Token
PP->>PP: verify token
end
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

@ -1,193 +0,0 @@
# Out-of-Process Plugin Architecture — `ocs_plugin_api`
**Status:** Design proposal
**Scope:** `crates/ocs_plugin_api` only (minimal host wiring, no plugin changes, no new crate)
---
## 1. Problem
The host currently loads external add-ons as `cdylib` libraries into its own process via `libloading`. `panic::catch_unwind` catches Rust panics, but a plugin can still corrupt host memory, segfault, or deadlock the UI thread. The fix is to run each plugin as a separate OS process and mediate all interaction through IPC.
---
## 2. Design
`ocs_plugin_api` becomes a dual-use library:
- **Plugin side:** unchanged API surface (`BuiltinPlugin`, `HostApi`, `CadModule`, `export_plugin!`).
- **Host side:** runtime that spawns plugin processes and handles their IPC requests.
Plugins remain `cdylib`s. The host spawns **itself** in runner mode (`--ocs-plugin-runner <socket> <cdylib>`) to load each cdylib in a child process and bridge to the host over `interprocess::local_socket`. The runner implementation lives inside `ocs_plugin_api` as a library module; no separate helper binary is needed, so the runner and host are always the same build and cannot get out of sync at deployment time.
---
## 3. Constraints
| Constraint | Handling |
|---|---|
| Plugin API unchanged | Trait/type signatures preserved. `document()` / `document_mut()` keep their signatures but return a local cached copy, so `API_VERSION` bumps to 3. |
| Only `ocs_plugin_api` modified | All new code lives here. The host needs only minimal call-site wiring in `src/plugin/external.rs`, `src/plugin/registry.rs`, and `src/app/plugin_host.rs`. |
| No new crate | Runner code lives inside `ocs_plugin_api`; the host executable serves as the runner process. |
| Platform-independent | `interprocess::local_socket` uses named pipes on Windows and Unix domain sockets elsewhere; self-spawning works on every host target. |
---
## 4. Architecture
```text
Host process Plugin process
┌─────────────────┐ local socket ┌─────────────────┐
│ HostSession │◄───────────────►│ HostApi proxy │
│ (document, UI) │ bincode frames │ (sends RPCs) │
└─────────────────┘ └─────────────────┘
▲ │
│ ▼
PluginManager cdylib loaded
(spawn / kill / by host in
supervise) runner mode
```
### 4.1 IPC protocol
All messages are length-framed and serialized with `bincode`.
**Host → plugin:**
- `GetManifest`, `GetRibbon`
- `Dispatch { cmd: String }`
- `InteractiveEvent { command_id, event }`
- `Shutdown`
**Plugin → host:**
- `PushInfo` / `PushOutput` / `PushError`
- `AddEntity(SerializedEntity)``Handle`
- `BumpGeometry`, `PushUndo`, `SetDirty`
- `ReadRecord`, `WriteRecord`, `RemoveRecord`
- `StartInteractive`, `PollInteractive`
- `DocumentSnapshot``SerializedDocument`
`SerializedEntity`, `SerializedRecord`, and `SerializedDocument` are `acadrust` types with `Serialize` / `Deserialize` derived.
### 4.2 Plugin-side runtime
`OpenCADStudio --ocs-plugin-runner <socket_name> <cdylib_path>`:
1. Loads the cdylib, validates `ocs_plugin_api_version`, calls `ocs_plugin_register`.
2. Connects to the host socket and answers `GetManifest` / `GetRibbon`.
3. Runs a request loop: dispatch commands, forward interactive events.
`PluginHostApi` implements `HostApi` by sending RPCs. `document()` / `document_mut()` return a local copy fetched from `DocumentSnapshot`; mutations are **not** automatically synced back. Plugins use `add_entity`, `write_record`, etc. for host-visible changes.
### 4.3 Host-side runtime
`ocs_plugin_api::process` provides:
- `PluginProcess::spawn(cdylib_path)` — creates a socket, launches the host executable in runner mode, accepts its connection.
- `PluginManager` — spawn all discovered plugins, supervise, kill.
- `serve_plugin_connection(stream, &mut dyn HostApi)` — host-side request handler.
The host creates a `HostSession` and passes it to `serve_plugin_connection`; existing document/undo logic is reused.
### 4.4 Ribbon
Ribbon types use `&'static str`, which cannot cross a socket. Define owned equivalents in `ocs_plugin_api::ribbon::owned` and convert for IPC. The host reconstructs `RibbonGroup` once per load (e.g., by leaking the owned strings), avoiding changes to ribbon rendering code.
### 4.5 Failure handling
| Failure | Behavior |
|---|---|
| Plugin crash / hang / malformed message | Host marks plugin dead, drops its ribbon tab, logs the error, and continues running. |
| Plugin panics | Caught inside the runner; an error response is returned to the host. |
| Spawn failure | Reported through `PluginManager` and shown in the Plugin Manager. |
---
## 5. Host Integration Points
1. **`src/plugin/external.rs`** — replace `libloading`-based `LoadedPlugin` with `PluginProcess::spawn`.
2. **`src/plugin/registry.rs`** — use `PluginProcess` for ribbon collection and command dispatch.
3. **`src/app/plugin_host.rs`** — add an IPC request bridge that maps incoming messages to `HostSession` calls.
No changes to `docs/plugin-template` or any other plugin.
---
## 6. Crate Changes
### 6.1 New files inside `crates/ocs_plugin_api`
```text
src/
ipc/
protocol.rs # HostRequest / PluginRequest / PluginResponse
transport.rs # framed read/write over local_socket
client.rs # plugin-side IpcClient + PluginHostApi
server.rs # host-side serve_plugin_connection
process.rs # PluginProcess / PluginManager
runner.rs # plugin runner logic invoked by host in runner mode
```
### 6.2 Dependencies
Add under the existing `host` feature:
```toml
[dependencies]
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 }
[features]
host = ["dep:acadrust", "dep:interprocess", "dep:serde", "dep:bincode", "dep:thiserror", "dep:libloading"]
```
---
## 7. API Version
Bump `API_VERSION` to `3` because `document()` / `document_mut()` semantics change from direct host references to local cached copies. v2 plugins are refused as usual; plugin authors recompile with `ApiVersion::CURRENT`.
---
## 8. Implementation Plan
1. Add dependencies to `Cargo.toml`.
2. Implement framed transport and protocol messages.
3. Implement runner logic in `runner.rs`.
4. Implement `PluginHostApi` proxy.
5. Implement host-side server and `PluginManager`.
6. Add owned ribbon conversions.
7. Wire the three host call sites and add `--ocs-plugin-runner` dispatch in `src/main.rs`.
8. Bump `API_VERSION` and update `docs/plugin-architecture.md`.
---
## 9. Testing
- **Unit:** protocol round-trip, ribbon conversion, proxy request emission.
- **Integration:** spawn a test plugin, verify dispatch and interactive command round-trip, kill the process and confirm the host survives.
- **Host:** update registry tests once `LoadedPlugin` is replaced by `PluginProcess`.
---
## 10. Compliance with `AGENT.md`
| Requirement | Status |
|---|---|
| `ocs_plugin_api` is a library, not a plugin | Yes |
| Plugin API source-compatible | Yes; signatures are unchanged. `document()` semantics change is gated by v3. |
| Separate processes + failure management | Yes |
| Platform-independent `interprocess` IPC | Yes |
| Only `ocs_plugin_api` modified | Code: yes. Host needs minimal wiring; unavoidable because `OpenCADStudio` / `HostSession` are host-private. |
| No new crate | Yes |
| Memory / process isolation | Yes |
---
## 11. Summary
`ocs_plugin_api` absorbs the plugin runtime: the host spawns itself in runner mode to load each cdylib in its own process, and all host/plugin interaction is serialized over `interprocess` local sockets. Plugin API signatures stay intact, host changes are limited to a few call sites, and no new crate is introduced.

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;