Merge pull request #881 from schoeller/v4-selection-broadcast
feat(plugin): emit SelectionChangedV4 to V4 plugins and add ocs_plugi…
This commit is contained in:
commit
f157b4a9ff
28 changed files with 625 additions and 221 deletions
264
crates/ocs_plugin_api/ARCHITECTURE.md
Normal file
264
crates/ocs_plugin_api/ARCHITECTURE.md
Normal 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)
|
||||||
|
|
@ -120,9 +120,10 @@ The frame format supports:
|
||||||
- Bi-directional, best-effort notifications (`NotificationEnvelope`)
|
- Bi-directional, best-effort notifications (`NotificationEnvelope`)
|
||||||
|
|
||||||
Because the socket is full-duplex, the host can push
|
Because the socket is full-duplex, the host can push
|
||||||
`HostNotification::DocumentChanged` or `InputLine` to a plugin at any time, and
|
`HostNotification::DocumentChangedV4`, `SelectionChangedV4`, or
|
||||||
the plugin can stream `PluginNotification::Output`/`Progress`/`Log` back while a
|
`DocumentTabClosed` to a plugin at any time, and the plugin can stream
|
||||||
long command is still running.
|
`PluginNotification::Output`/`Progress`/`Log` back while a long command is still
|
||||||
|
running.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
sequenceDiagram
|
sequenceDiagram
|
||||||
|
|
@ -420,7 +421,8 @@ std::thread::spawn(move || {
|
||||||
|
|
||||||
### Further reading
|
### 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 template: [`../../docs/plugin-template`](../../docs/plugin-template)
|
||||||
- Plugin marketplace registry: [`../../plugins/README.md`](../../plugins/README.md)
|
- Plugin marketplace registry: [`../../plugins/README.md`](../../plugins/README.md)
|
||||||
- REPL design notes: [`../DESIGN_REPL.md`](../DESIGN_REPL.md)
|
- REPL design notes: [`../DESIGN_REPL.md`](../DESIGN_REPL.md)
|
||||||
- Python REPL plugin: [`../ocs_python_repl/README.md`](../ocs_python_repl/README.md)
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -69,6 +69,8 @@ pub enum HostNotification {
|
||||||
DocumentChangedV4 { tab_id: u64, version: u64 },
|
DocumentChangedV4 { tab_id: u64, version: u64 },
|
||||||
/// V4 tab closed notification. Discriminant 6.
|
/// V4 tab closed notification. Discriminant 6.
|
||||||
DocumentTabClosed { tab_id: u64 },
|
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.
|
/// Fallback for notification variants added in future minor revisions.
|
||||||
/// Carries the raw bincode payload so an older peer can ignore it without
|
/// Carries the raw bincode payload so an older peer can ignore it without
|
||||||
/// failing deserialization.
|
/// failing deserialization.
|
||||||
|
|
@ -112,6 +114,13 @@ impl Serialize for HostNotification {
|
||||||
bincode::serialize_into(&mut bytes, tab_id)
|
bincode::serialize_into(&mut bytes, tab_id)
|
||||||
.map_err(serde::ser::Error::custom)?;
|
.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),
|
HostNotification::Unknown(raw) => bytes.extend_from_slice(raw),
|
||||||
}
|
}
|
||||||
bytes.serialize(serializer)
|
bytes.serialize(serializer)
|
||||||
|
|
@ -146,6 +155,9 @@ impl<'de> Deserialize<'de> for HostNotification {
|
||||||
6 => bincode::deserialize(rest)
|
6 => bincode::deserialize(rest)
|
||||||
.map(|tab_id| HostNotification::DocumentTabClosed { tab_id })
|
.map(|tab_id| HostNotification::DocumentTabClosed { tab_id })
|
||||||
.map_err(serde::de::Error::custom),
|
.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)),
|
_ => Ok(HostNotification::Unknown(bytes)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,12 @@
|
||||||
//! Maintains a per-tab shared-memory document view keyed by the stable
|
//! 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
|
//! `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.
|
//! 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::collections::HashMap;
|
||||||
use std::sync::{Mutex, OnceLock};
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,21 @@
|
||||||
//!
|
//!
|
||||||
//! Built only with the `host` feature because it needs `acadrust`-typed
|
//! Built only with the `host` feature because it needs `acadrust`-typed
|
||||||
//! messages and the plugin runner binary.
|
//! 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")]
|
#[cfg(feature = "host")]
|
||||||
pub mod client;
|
pub mod client;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,16 @@
|
||||||
//! (expecting a response) or a response (to a previous request). This lets the
|
//! (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
|
//! 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.
|
//! 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};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,12 @@
|
||||||
//! Length-framed transport over `interprocess::local_socket` streams.
|
//! 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};
|
use std::io::{Read, Write};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -761,7 +761,6 @@ impl DocumentReader for EmptyDocumentReader {
|
||||||
#[cfg(all(test, feature = "host"))]
|
#[cfg(all(test, feature = "host"))]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::atomic::{AtomicU64, Ordering};
|
use std::sync::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::Mutex as StdMutex;
|
|
||||||
use std::thread;
|
use std::thread;
|
||||||
|
|
||||||
use interprocess::local_socket::{
|
use interprocess::local_socket::{
|
||||||
|
|
@ -772,10 +771,9 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::ipc::transport::recv;
|
use crate::ipc::transport::recv;
|
||||||
use crate::ipc::v4::protocol::{HostToPluginV4, PluginToHostV4};
|
use crate::ipc::v4::protocol::{HostToPluginV4, PluginToHostV4};
|
||||||
|
use crate::test_lock::ENV_LOCK;
|
||||||
use acadrust::entities::Point;
|
use acadrust::entities::Point;
|
||||||
|
|
||||||
static ENV_LOCK: StdMutex<()> = StdMutex::new(());
|
|
||||||
|
|
||||||
fn unique_socket_name() -> String {
|
fn unique_socket_name() -> String {
|
||||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||||
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,15 @@
|
||||||
//! requests, responses, and best-effort notifications over a single local
|
//! requests, responses, and best-effort notifications over a single local
|
||||||
//! socket. The V3 files (`src/ipc/{protocol,client,server}.rs`) are left
|
//! socket. The V3 files (`src/ipc/{protocol,client,server}.rs`) are left
|
||||||
//! untouched; all V4 logic lives in this module.
|
//! 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 client;
|
||||||
pub mod protocol;
|
pub mod protocol;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,23 @@
|
||||||
//! V4 wire frames and envelope types.
|
//! 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};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,17 @@
|
||||||
//! For binary compatibility, the host and every plugin must resolve the same
|
//! 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
|
//! `acadrust` source. The host does this via a `[patch.crates-io]` entry in
|
||||||
//! `Cargo.toml`; out-of-tree plugins should copy that exact patch.
|
//! `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 manifest;
|
||||||
pub mod ribbon;
|
pub mod ribbon;
|
||||||
|
|
@ -64,5 +75,15 @@ pub use type_registry::{
|
||||||
};
|
};
|
||||||
pub use version_info::get_embedded_version_info_json;
|
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")]
|
#[cfg(feature = "host")]
|
||||||
pub use process::{DispatchResult, PluginError, PluginManager, PluginProcess};
|
pub use process::{DispatchResult, PluginError, PluginManager, PluginProcess};
|
||||||
|
|
|
||||||
|
|
@ -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
|
/// Host plugin API version. Bump when the host runtime surface breaks
|
||||||
/// compatibility. v2 added `HostApi::start_interactive`. v3 changes
|
/// compatibility. v2 added `HostApi::start_interactive`. v3 changes
|
||||||
|
|
@ -56,12 +68,10 @@ pub fn host_accepts_plugin_version(plugin_major: u32) -> bool {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::sync::Mutex;
|
use crate::test_lock::ENV_LOCK;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn current_matches_const() {
|
fn current_matches_const() {
|
||||||
assert_eq!(ApiVersion::CURRENT.major, API_VERSION);
|
assert_eq!(ApiVersion::CURRENT.major, API_VERSION);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,22 @@
|
||||||
//! Process management for out-of-process plugins.
|
//! 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::path::{Path, PathBuf};
|
||||||
use std::process::{Child, Command, Stdio};
|
use std::process::{Child, Command, Stdio};
|
||||||
|
|
@ -1119,6 +1137,7 @@ mod timeout_tests {
|
||||||
};
|
};
|
||||||
use crate::ipc::transport::{recv, send};
|
use crate::ipc::transport::{recv, send};
|
||||||
use crate::ribbon::owned::OwnedPluginManifest;
|
use crate::ribbon::owned::OwnedPluginManifest;
|
||||||
|
use crate::test_lock::ENV_LOCK;
|
||||||
use acadrust::xdata::ExtendedDataRecord;
|
use acadrust::xdata::ExtendedDataRecord;
|
||||||
use acadrust::{CadDocument, EntityType, Handle};
|
use acadrust::{CadDocument, EntityType, Handle};
|
||||||
use interprocess::local_socket::{
|
use interprocess::local_socket::{
|
||||||
|
|
@ -1130,8 +1149,6 @@ mod timeout_tests {
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
static ENV_LOCK: StdMutex<()> = StdMutex::new(());
|
|
||||||
|
|
||||||
struct EmptyReader;
|
struct EmptyReader;
|
||||||
|
|
||||||
impl DocumentReader for EmptyReader {
|
impl DocumentReader for EmptyReader {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,19 @@
|
||||||
//! Process manager for out-of-process plugins.
|
//! 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::path::Path;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
|
||||||
|
|
@ -517,7 +517,7 @@ mod tests {
|
||||||
|
|
||||||
use std::sync::Mutex as StdMutex;
|
use std::sync::Mutex as StdMutex;
|
||||||
|
|
||||||
static ENV_LOCK: StdMutex<()> = StdMutex::new(());
|
use crate::test_lock::ENV_LOCK;
|
||||||
|
|
||||||
fn set_test_env() {
|
fn set_test_env() {
|
||||||
std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", "2");
|
std::env::set_var("OCS_PLUGIN_CALL_TIMEOUT_SECS", "2");
|
||||||
|
|
@ -532,6 +532,13 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn execute_code_timeout_floor_is_at_least_60s_and_env_override() {
|
fn execute_code_timeout_floor_is_at_least_60s_and_env_override() {
|
||||||
let _guard = ENV_LOCK.lock().unwrap();
|
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");
|
std::env::remove_var("OCS_PLUGIN_EXECUTE_TIMEOUT_SECS");
|
||||||
assert!(
|
assert!(
|
||||||
request_timeout("ExecuteCode") >= Duration::from_secs(60),
|
request_timeout("ExecuteCode") >= Duration::from_secs(60),
|
||||||
|
|
@ -544,7 +551,19 @@ mod tests {
|
||||||
Duration::from_secs(120),
|
Duration::from_secs(120),
|
||||||
"env override should be respected"
|
"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]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,18 @@
|
||||||
//! (`--ocs-plugin-runner <socket> <cdylib>`). Keeping the runner code inside
|
//! (`--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
|
//! `ocs_plugin_api` means the host only needs to know the CLI contract, not the
|
||||||
//! internal plugin-loading and IPC details.
|
//! 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::cell::RefCell;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,21 @@
|
||||||
//! This module is generic over the payload type so that the V3 simplified view
|
//! 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
|
//! and the V4 bincode-per-entity view can share the same double-buffered
|
||||||
//! control-page logic.
|
//! 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::fs::OpenOptions;
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,15 @@
|
||||||
//! The JSON embedded here is produced by tracing a curated allow-list of
|
//! The JSON embedded here is produced by tracing a curated allow-list of
|
||||||
//! `acadrust` types with `serde-reflection` and mapping the result into a
|
//! `acadrust` types with `serde-reflection` and mapping the result into a
|
||||||
//! stable, language-binding-friendly schema defined in
|
//! 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::*;
|
pub use crate::type_registry_types::*;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,16 @@
|
||||||
//! Embedded version metadata generated at build time.
|
//! 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;
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,7 @@ concrete types:
|
||||||
| Command line | `push_info`, `push_output`, `push_error` |
|
| Command line | `push_info`, `push_output`, `push_error` |
|
||||||
| Undo / dirty | `push_undo`, `set_dirty` |
|
| Undo / dirty | `push_undo`, `set_dirty` |
|
||||||
| Tab | `tab_index()` |
|
| 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
|
### `export_plugin!` — the C-ABI export
|
||||||
|
|
||||||
|
|
@ -309,7 +310,7 @@ Two timeouts protect the host from a stuck runner:
|
||||||
|
|
||||||
| Timeout | Env var | Default | Floor |
|
| 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 |
|
| 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
|
A call timeout covers the full round-trip, including any nested plugin→host
|
||||||
|
|
@ -402,6 +403,7 @@ Next:
|
||||||
| Piece | Location |
|
| Piece | Location |
|
||||||
|-------|----------|
|
|-------|----------|
|
||||||
| Contract crate + runtime | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) |
|
| 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) |
|
| 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 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` |
|
| Host plugin integration | `src/plugin/`, `src/app/plugin_host.rs` |
|
||||||
|
|
|
||||||
|
|
@ -193,6 +193,17 @@ fn entity_json(e: &acadrust::EntityType) -> Value {
|
||||||
impl OpenCADStudio {
|
impl OpenCADStudio {
|
||||||
/// Handle one JSON request line and return the JSON response.
|
/// Handle one JSON request line and return the JSON response.
|
||||||
pub(crate) fn automation_op(&mut self, line: &str) -> Value {
|
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) {
|
let req: Value = match serde_json::from_str(line) {
|
||||||
Ok(v) => v,
|
Ok(v) => v,
|
||||||
Err(e) => return err(format!("invalid JSON: {e}")),
|
Err(e) => return err(format!("invalid JSON: {e}")),
|
||||||
|
|
@ -201,6 +212,7 @@ impl OpenCADStudio {
|
||||||
"new" => {
|
"new" => {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
self.tabs[i].scene.document = acadrust::CadDocument::new();
|
self.tabs[i].scene.document = acadrust::CadDocument::new();
|
||||||
|
self.tabs[i].scene.deselect_all();
|
||||||
self.tabs[i].current_path = None;
|
self.tabs[i].current_path = None;
|
||||||
// The headless session starts on the welcome (Start) tab, which
|
// The headless session starts on the welcome (Start) tab, which
|
||||||
// blocks drawing commands; turn it into a real drawing.
|
// blocks drawing commands; turn it into a real drawing.
|
||||||
|
|
@ -224,6 +236,7 @@ impl OpenCADStudio {
|
||||||
Ok(doc) => {
|
Ok(doc) => {
|
||||||
let i = self.active_tab;
|
let i = self.active_tab;
|
||||||
self.tabs[i].scene.document = doc;
|
self.tabs[i].scene.document = doc;
|
||||||
|
self.tabs[i].scene.deselect_all();
|
||||||
crate::app::style_ops::ensure_standard_styles(
|
crate::app::style_ops::ensure_standard_styles(
|
||||||
&mut self.tabs[i].scene.document,
|
&mut self.tabs[i].scene.document,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -691,6 +691,10 @@ pub(super) struct OpenCADStudio {
|
||||||
/// keep their manifest listed but drop their ribbon tab and command
|
/// keep their manifest listed but drop their ribbon tab and command
|
||||||
/// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`].
|
/// dispatch. Persisted via [`settings::UserSettings::disabled_plugins`].
|
||||||
disabled_plugins: rustc_hash::FxHashSet<String>,
|
disabled_plugins: rustc_hash::FxHashSet<String>,
|
||||||
|
/// `(tab id, selection fingerprint)` 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
|
/// External add-on packages found in the plugins folder, refreshed when the
|
||||||
/// Plugin Manager opens.
|
/// Plugin Manager opens.
|
||||||
external_plugins: Vec<crate::plugin::external::ExternalPlugin>,
|
external_plugins: Vec<crate::plugin::external::ExternalPlugin>,
|
||||||
|
|
@ -3246,6 +3250,8 @@ impl OpenCADStudio {
|
||||||
attr_editor_tab: crate::ui::window::attribute_editor::AttrTab::Attribute,
|
attr_editor_tab: crate::ui::window::attribute_editor::AttrTab::Attribute,
|
||||||
attr_editor_selected: 0,
|
attr_editor_selected: 0,
|
||||||
disabled_plugins: rustc_hash::FxHashSet::default(),
|
disabled_plugins: rustc_hash::FxHashSet::default(),
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
last_plugin_selection: None,
|
||||||
external_plugins: Vec::new(),
|
external_plugins: Vec::new(),
|
||||||
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
|
loaded_plugin_ids: rustc_hash::FxHashSet::default(),
|
||||||
plugin_load_errors: rustc_hash::FxHashMap::default(),
|
plugin_load_errors: rustc_hash::FxHashMap::default(),
|
||||||
|
|
|
||||||
|
|
@ -234,6 +234,25 @@ impl OpenCADStudio {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emit `SelectionChangedV4` to V4 plugins when the active tab's selection
|
||||||
|
/// set actually changed since the last broadcast.
|
||||||
|
#[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 fingerprint = self.tabs[i].scene.selection_fingerprint();
|
||||||
|
let key = (tab_id, fingerprint);
|
||||||
|
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<Message> {
|
pub fn update(&mut self, msg: Message) -> Task<Message> {
|
||||||
let perf_started = crate::perf::enabled().then(Instant::now);
|
let perf_started = crate::perf::enabled().then(Instant::now);
|
||||||
let perf_label = perf_message_label(&msg);
|
let perf_label = perf_message_label(&msg);
|
||||||
|
|
@ -278,6 +297,11 @@ impl OpenCADStudio {
|
||||||
// The block panel watches the drawing's block list and rebuilds its
|
// The block panel watches the drawing's block list and rebuilds its
|
||||||
// thumbnails whenever the names change (BLOCK define, file open, …).
|
// thumbnails whenever the names change (BLOCK define, file open, …).
|
||||||
self.refresh_block_palette_if_stale();
|
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
|
// OTRACK acquires tracking points only while a command or grip drag is
|
||||||
// running; drop them once neither is active so the temporary tracking
|
// running; drop them once neither is active so the temporary tracking
|
||||||
// points / vectors disappear when the command ends (issue #64).
|
// points / vectors disappear when the command ends (issue #64).
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,15 @@ pub fn on_tab_closed(tab_id: u64) {
|
||||||
broadcast(HostNotification::DocumentTabClosed { tab_id });
|
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<acadrust::Handle>) {
|
||||||
|
broadcast(HostNotification::SelectionChangedV4 { tab_id, handles });
|
||||||
|
}
|
||||||
|
|
||||||
/// V4 notification handler installed on the plugin manager.
|
/// V4 notification handler installed on the plugin manager.
|
||||||
///
|
///
|
||||||
/// Forwards REPL status messages to the log; other notifications are handled
|
/// Forwards REPL status messages to the log; other notifications are handled
|
||||||
|
|
|
||||||
|
|
@ -203,8 +203,11 @@ impl Scene {
|
||||||
|
|
||||||
/// If any handle belongs to a selectable group, also select every member.
|
/// If any handle belongs to a selectable group, also select every member.
|
||||||
pub fn expand_selection_for_groups(&mut self, handles: &[Handle]) {
|
pub fn expand_selection_for_groups(&mut self, handles: &[Handle]) {
|
||||||
|
let previous_len = self.selected.len();
|
||||||
self.selected
|
self.selected
|
||||||
.extend(self.handles_expanded_for_selectable_groups(handles));
|
.extend(self.handles_expanded_for_selectable_groups(handles));
|
||||||
self.bump_selection();
|
if self.selected.len() != previous_len {
|
||||||
|
self.bump_selection_set();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1486,6 +1486,9 @@ pub struct Scene {
|
||||||
/// pick only refreshes the GPU xray overlay (cheap) instead of bumping
|
/// pick only refreshes the GPU xray overlay (cheap) instead of bumping
|
||||||
/// `geometry_epoch` and re-tessellating the whole model.
|
/// `geometry_epoch` and re-tessellating the whole model.
|
||||||
pub selection_generation: u64,
|
pub selection_generation: u64,
|
||||||
|
/// Cached fingerprint of `selected`, recomputed lazily after mutations.
|
||||||
|
selection_fingerprint_cache: u64,
|
||||||
|
selection_fingerprint_dirty: bool,
|
||||||
/// Cached tessellation of all visible entity wires for the current layout.
|
/// Cached tessellation of all visible entity wires for the current layout.
|
||||||
/// Keyed by `(geometry_epoch, camera_generation)` so a camera change
|
/// Keyed by `(geometry_epoch, camera_generation)` so a camera change
|
||||||
/// invalidates the cull-dependent wire list as well as a geometry change.
|
/// invalidates the cull-dependent wire list as well as a geometry change.
|
||||||
|
|
@ -1865,6 +1868,8 @@ impl Scene {
|
||||||
projection_bounds_epoch: std::cell::Cell::new(0),
|
projection_bounds_epoch: std::cell::Cell::new(0),
|
||||||
block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed),
|
block_epoch: GEOMETRY_EPOCH.fetch_add(1, Ordering::Relaxed),
|
||||||
selection_generation: 0,
|
selection_generation: 0,
|
||||||
|
selection_fingerprint_cache: 0,
|
||||||
|
selection_fingerprint_dirty: false,
|
||||||
wire_cache: RefCell::new(None),
|
wire_cache: RefCell::new(None),
|
||||||
interaction_index_cache: RefCell::new(Vec::new()),
|
interaction_index_cache: RefCell::new(Vec::new()),
|
||||||
interaction_index_pending_key: std::cell::Cell::new(None),
|
interaction_index_pending_key: std::cell::Cell::new(None),
|
||||||
|
|
@ -2618,6 +2623,11 @@ impl Scene {
|
||||||
self.selection_generation = self.selection_generation.wrapping_add(1);
|
self.selection_generation = self.selection_generation.wrapping_add(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn bump_selection_set(&mut self) {
|
||||||
|
self.selection_fingerprint_dirty = true;
|
||||||
|
self.bump_selection();
|
||||||
|
}
|
||||||
|
|
||||||
/// Milliseconds after the last camera change during which the view counts as
|
/// Milliseconds after the last camera change during which the view counts as
|
||||||
/// "actively navigating" for interaction-LOD purposes.
|
/// "actively navigating" for interaction-LOD purposes.
|
||||||
const NAV_SETTLE_MS: u128 = 130;
|
const NAV_SETTLE_MS: u128 = 130;
|
||||||
|
|
@ -4582,6 +4592,7 @@ impl Scene {
|
||||||
.extend(self.selected.iter().copied());
|
.extend(self.selected.iter().copied());
|
||||||
self.selected.clear();
|
self.selected.clear();
|
||||||
self.selected_order.clear();
|
self.selected_order.clear();
|
||||||
|
self.bump_selection_set();
|
||||||
self.bump_entities(&changes);
|
self.bump_entities(&changes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
// Auto-split from scene/mod.rs. Pure text-move; behaviour unchanged.
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
use std::collections::hash_map::DefaultHasher;
|
||||||
|
use std::hash::{Hash, Hasher};
|
||||||
|
|
||||||
impl Scene {
|
impl Scene {
|
||||||
// ── Selection ─────────────────────────────────────────────────────────
|
// ── Selection ─────────────────────────────────────────────────────────
|
||||||
/// Treat a classic LEADER and its attached annotation as one logical object.
|
/// Treat a classic LEADER and its attached annotation as one logical object.
|
||||||
|
|
@ -37,8 +40,11 @@ impl Scene {
|
||||||
}
|
}
|
||||||
pub fn select_entity(&mut self, handle: Handle, exclusive: bool) {
|
pub fn select_entity(&mut self, handle: Handle, exclusive: bool) {
|
||||||
let handles = self.handles_expanded_for_leader_annotations(&[handle]);
|
let handles = self.handles_expanded_for_leader_annotations(&[handle]);
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
if exclusive {
|
if exclusive {
|
||||||
|
changed = self.selected.len() != handles.len()
|
||||||
|
|| handles.iter().any(|handle| !self.selected.contains(handle));
|
||||||
self.selected.clear();
|
self.selected.clear();
|
||||||
self.selected_order.clear();
|
self.selected_order.clear();
|
||||||
}
|
}
|
||||||
|
|
@ -46,15 +52,35 @@ impl Scene {
|
||||||
for handle in handles {
|
for handle in handles {
|
||||||
if self.selected.insert(handle) {
|
if self.selected.insert(handle) {
|
||||||
self.selected_order.push(handle);
|
self.selected_order.push(handle);
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.bump_selection();
|
if changed {
|
||||||
|
self.bump_selection_set();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn deselect_all(&mut self) {
|
pub fn deselect_all(&mut self) {
|
||||||
|
if self.selected.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
self.selected.clear();
|
self.selected.clear();
|
||||||
self.selected_order.clear();
|
self.selected_order.clear();
|
||||||
self.bump_selection();
|
self.bump_selection_set();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn selection_fingerprint(&mut self) -> u64 {
|
||||||
|
if self.selection_fingerprint_dirty {
|
||||||
|
let mut fingerprint = self.selected.len() as u64;
|
||||||
|
for handle in &self.selected {
|
||||||
|
let mut hasher = DefaultHasher::new();
|
||||||
|
handle.hash(&mut hasher);
|
||||||
|
fingerprint ^= hasher.finish();
|
||||||
|
}
|
||||||
|
self.selection_fingerprint_cache = fingerprint;
|
||||||
|
self.selection_fingerprint_dirty = false;
|
||||||
|
}
|
||||||
|
self.selection_fingerprint_cache
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn selected_handles_in_order(&self) -> Vec<Handle> {
|
pub(crate) fn selected_handles_in_order(&self) -> Vec<Handle> {
|
||||||
|
|
@ -103,7 +129,7 @@ impl Scene {
|
||||||
order.extend(added);
|
order.extend(added);
|
||||||
self.selected = selected;
|
self.selected = selected;
|
||||||
self.selected_order = order;
|
self.selected_order = order;
|
||||||
self.bump_selection();
|
self.bump_selection_set();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +144,7 @@ impl Scene {
|
||||||
}
|
}
|
||||||
|
|
||||||
if changed {
|
if changed {
|
||||||
self.bump_selection();
|
self.bump_selection_set();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,7 +225,7 @@ impl Scene {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if added > 0 {
|
if added > 0 {
|
||||||
self.bump_selection();
|
self.bump_selection_set();
|
||||||
}
|
}
|
||||||
added
|
added
|
||||||
}
|
}
|
||||||
|
|
@ -223,7 +249,9 @@ impl Scene {
|
||||||
self.selected_order.push(h);
|
self.selected_order.push(h);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
self.bump_selection();
|
if self.selected != prev {
|
||||||
|
self.bump_selection_set();
|
||||||
|
}
|
||||||
self.selected.len()
|
self.selected.len()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -709,7 +737,8 @@ impl Scene {
|
||||||
|
|
||||||
let mut handle_set: HashSet<Handle> = HashSet::default();
|
let mut handle_set: HashSet<Handle> = HashSet::default();
|
||||||
let mut erased: Vec<(Handle, ChangeKind)> = Vec::new();
|
let mut erased: Vec<(Handle, ChangeKind)> = Vec::new();
|
||||||
let mut highlight_changed = false;
|
let mut selection_changed = false;
|
||||||
|
let mut hover_changed = false;
|
||||||
|
|
||||||
for &h in &erase_handles {
|
for &h in &erase_handles {
|
||||||
// Objects on a locked layer can't be erased.
|
// Objects on a locked layer can't be erased.
|
||||||
|
|
@ -724,11 +753,11 @@ impl Scene {
|
||||||
self.delete_solid_history(h);
|
self.delete_solid_history(h);
|
||||||
self.remember_removed_cache_categories(h);
|
self.remember_removed_cache_categories(h);
|
||||||
self.document.remove_entity_arc(h);
|
self.document.remove_entity_arc(h);
|
||||||
highlight_changed |= self.selected.remove(&h);
|
selection_changed |= self.selected.remove(&h);
|
||||||
self.selected_order.retain(|selected| *selected != h);
|
self.selected_order.retain(|selected| *selected != h);
|
||||||
if self.hover_highlight == Some(h) {
|
if self.hover_highlight == Some(h) {
|
||||||
self.hover_highlight = None;
|
self.hover_highlight = None;
|
||||||
highlight_changed = true;
|
hover_changed = true;
|
||||||
}
|
}
|
||||||
self.hatches.remove(&h);
|
self.hatches.remove(&h);
|
||||||
self.images.remove(&h);
|
self.images.remove(&h);
|
||||||
|
|
@ -738,7 +767,9 @@ impl Scene {
|
||||||
handle_set.insert(h);
|
handle_set.insert(h);
|
||||||
erased.push((h, ChangeKind::Removed));
|
erased.push((h, ChangeKind::Removed));
|
||||||
}
|
}
|
||||||
if highlight_changed {
|
if selection_changed {
|
||||||
|
self.bump_selection_set();
|
||||||
|
} else if hover_changed {
|
||||||
self.bump_selection();
|
self.bump_selection();
|
||||||
}
|
}
|
||||||
// Capture exactly the group objects that this erase will rewrite, plus
|
// Capture exactly the group objects that this erase will rewrite, plus
|
||||||
|
|
@ -838,3 +869,29 @@ impl Scene {
|
||||||
restored
|
restored
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_fingerprint_tracks_final_set_only() {
|
||||||
|
let mut scene = Scene::default();
|
||||||
|
let first = Handle::new(1);
|
||||||
|
let second = Handle::new(2);
|
||||||
|
scene.select_entity(first, false);
|
||||||
|
scene.select_entity(second, false);
|
||||||
|
let fingerprint = scene.selection_fingerprint();
|
||||||
|
assert!(!scene.selection_fingerprint_dirty);
|
||||||
|
|
||||||
|
scene.deselect_all();
|
||||||
|
scene.select_entity(second, false);
|
||||||
|
scene.select_entity(first, false);
|
||||||
|
assert!(scene.selection_fingerprint_dirty);
|
||||||
|
assert_eq!(scene.selection_fingerprint(), fingerprint);
|
||||||
|
|
||||||
|
scene.set_hover_highlight(Some(Handle::new(3)));
|
||||||
|
assert!(!scene.selection_fingerprint_dirty);
|
||||||
|
assert_eq!(scene.selection_fingerprint(), fingerprint);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue