cad-editor/crates/ocs_plugin_api/ARCHITECTURE.md
2026-08-26 21:48:02 +03:00

13 KiB

ocs_plugin_api — Internal Architecture

Internal-facing architecture documentation for crates/ocs_plugin_api. For plugin-author tutorials see README.md; for host/plugin integration design goals see ../../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 API_VERSION, API_VERSION_MIN_SUPPORTED, ApiVersion compatibility, runtime OCS_PLUGIN_MAX_API_VERSION gate.
src/ribbon CadModule trait and plain-data ribbon types (RibbonGroup, ToolDef, IconKind, ModuleEvent).
src/host.rs HostApi plugin-facing runtime trait, BuiltinPlugin entry-point trait, export_plugin! macro, HostNotification, PluginNotification.
src/ipc/mod.rs IPC layer public exports.
src/ipc/protocol.rs V2/V3 request/response enums (HostRequest, HostResponse, PluginRequest, PluginResponse), RunnerHandshake, PLUGIN_TOKEN_ENV.
src/ipc/transport.rs Length-framed bincode send/recv over interprocess::local_socket::Stream.
src/ipc/client.rs Plugin-side V2/V3 IpcClient and PluginHostApi proxy.
src/ipc/server.rs Host-side handler that applies a PluginRequest to a HostApi implementation.
src/ipc/v4 Multiplexed V4 protocol: frames, notifications, correlation ids.
src/ipc/proxy.rs Optional TCP request proxy for plugin child processes.
src/process.rs PluginProcess lifecycle: spawn, handshake, call, timeouts, NullHost, io draining.
src/process/manager.rs PluginManager: owns loaded plugins, dispatch routing, notification broadcast, V4 gating.
src/process/v4.rs Host-side V4 connection and reader thread.
src/runner.rs Runner entry point and V2/V3 vs V4 request loops; loads cdylib via libloading.
src/shm.rs Shared-memory document snapshot layout, DocumentSnapshotStore, SharedDocumentReader, V3/V4 view data.
src/host_v4.rs Host-side per-tab V4 snapshot manager.
src/type_registry.rs / src/type_registry_types.rs Build-time serde-reflection registry, embedded JSON, schema types.
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).

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

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 PluginRequests from the runner and handles them inline.

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.

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:

  • 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.

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 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 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. V4-only delivery via PluginManager::broadcast_notification. Currently emitted examples: DocumentChangedV4, SelectionChangedV4, DocumentTabClosed.
  • Plugin → host: PluginNotification in 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.
  • 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.

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