forked from 1b-agent/opencadstudio-upstream
feat(plugin): emit SelectionChangedV4 to V4 plugins and add ocs_plugin_api architecture docs
- Add HostNotification::SelectionChangedV4 { tab_id, handles } with discriminant 7
- Broadcast active-tab selection changes at update() and automation_op boundaries
- Use stable order-independent signature to avoid spurious hover notifications
- Clear Scene selection in automation new/open to avoid stale handles
- Add crates/ocs_plugin_api/ARCHITECTURE.md and module-level rustdoc
- Cross-link README.md and docs/plugin-architecture.md
- Fix test env race with shared ENV_LOCK and EnvVarGuard helpers
This commit is contained in:
1 parent
f4a292a6b2
commit
f20bf93dfe
25 files changed
+592
-17
No files matched your search
@@ -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)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user