forked from 1b-agent/opencadstudio-upstream
Merge remote-tracking branch 'origin/main' into HEAD
This commit is contained in:
commit
1c52a2afcf
121 files changed
+946
-538
No files matched your search
@@ -44,16 +44,18 @@ The host and each plugin run in separate OS processes. The host re-executes itse
|
||||
|
||||
## Versioning and ABI stability
|
||||
|
||||
- `API_VERSION` (currently `4`) is the host's advertised major.
|
||||
- `API_VERSION` (currently `5`) 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).
|
||||
- `OCS_PLUGIN_MAX_API_VERSION` can cap the accepted major at runtime (e.g. `4` to disable V5).
|
||||
- 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)).
|
||||
- V5 introduces `BuiltinPlugin::on_load`, invoked once after the runner connects.
|
||||
|
||||
The runtime enforces two gates:
|
||||
The runtime enforces three 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.
|
||||
3. For v4+, the plugin must declare `rustc_version`, and `rustc_versions_compatible(host_rustc_version(), plugin_rustc_version)` must be true.
|
||||
|
||||
---
|
||||
|
||||
@@ -71,18 +73,21 @@ sequenceDiagram
|
||||
PP->>R: spawn --ocs-plugin-runner <socket> <cdylib>
|
||||
R->>L: unsafe { load(cdylib_path) }
|
||||
L-->>R: Box<dyn BuiltinPlugin>
|
||||
alt API v4
|
||||
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
|
||||
opt API v5+
|
||||
R->>L: on_load(HostApi)
|
||||
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
|
||||
PP->>PP: verify api_version + ABI metadata gates
|
||||
alt accepted
|
||||
PP-->>PM: success, keep alive
|
||||
else rejected
|
||||
@@ -215,7 +220,7 @@ Broadcasting is V4-gated inside `PluginManager::broadcast_notification`: V2/V3 p
|
||||
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).
|
||||
- **Version info** (`OUT_DIR/version_info.json`) — host version, `ocs_plugin_api` version, `acadrust` version and source, `rustc` version, API versions, build timestamp. See [`src/version_info.rs`](src/version_info.rs).
|
||||
|
||||
Both are accessible without enabling the `host` feature.
|
||||
|
||||
@@ -242,6 +247,7 @@ This is defense-in-depth, not a sandbox: plugins execute native code.
|
||||
| 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. |
|
||||
| Rustc mismatch (v4+) | Host refuses plugin to avoid cross-compiler ABI crashes. |
|
||||
| Unknown notification discriminant | Deserializes to `HostNotification::Unknown`; plugin can ignore. |
|
||||
|
||||
---
|
||||
|
||||
@@ -9,7 +9,7 @@ cheaply. The runtime host surface is enabled by the `host` feature.
|
||||
## Plugin Architecture
|
||||
|
||||
OCS is scriptable and extensible through a versioned plugin API. The API
|
||||
supports three major protocol generations: **V2**, **V3**, and **V4**. Each
|
||||
supports four major generations: **V2**, **V3**, **V4**, and **V5**. Each
|
||||
generation keeps the previous ABI stable by appending new vtable entries and new
|
||||
enum variants at the end, so older plugins continue to load on newer hosts.
|
||||
|
||||
@@ -18,12 +18,17 @@ enum variants at the end, so older plugins continue to load on newer hosts.
|
||||
| **V2** | Out-of-process runner | Synchronous commands + interactive point collection (`HostApi::start_interactive`) | Baseline |
|
||||
| **V3** | Out-of-process runner | Local cached `document()`/`document_mut()` + shared-memory `DocumentReader`/`document_view` for large reads | New vtable entries appended; new `PluginRequest`/`PluginResponse` variants appended |
|
||||
| **V4** | Out-of-process runner over a multiplexed local socket | Full-duplex notifications + asynchronous REPL `ExecuteCode` | New `HostToPluginV4`/`PluginToHostV4` frame layer; `BuiltinPlugin::on_notification` and `start_execute_code` appended to trait |
|
||||
| **V5** | V4 multiplexed runner | One-shot `BuiltinPlugin::on_load` initialization | Lifecycle callback appended to `BuiltinPlugin` |
|
||||
|
||||
The host controls which generations are accepted at runtime through
|
||||
`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `3` to disable V4). The
|
||||
current host advertises [`API_VERSION = 4`](src/manifest.rs) and supports plugins
|
||||
`OCS_PLUGIN_MAX_API_VERSION` (e.g. `2` for V2-only mode, `4` to disable V5). The
|
||||
current host advertises [`API_VERSION = 5`](src/manifest.rs) and supports plugins
|
||||
back to `API_VERSION_MIN_SUPPORTED = 2`.
|
||||
|
||||
V5 calls `BuiltinPlugin::on_load` once after the runner connects and before the
|
||||
first user command. Plugins can cache `HostApi::plugin_request_sender()` there
|
||||
for worker-thread access. A panic in the callback fails plugin loading.
|
||||
|
||||
### High-level layout
|
||||
|
||||
```mermaid
|
||||
@@ -263,7 +268,7 @@ static MANIFEST: PluginManifest = PluginManifest {
|
||||
name: "Hello Plugin",
|
||||
version: "0.1.0",
|
||||
description: "A minimal example plugin.",
|
||||
api_version: ApiVersion { major: 4 },
|
||||
api_version: ApiVersion::CURRENT,
|
||||
ribbon_order: 100,
|
||||
xdata_apps: &[],
|
||||
command_prefixes: &["HELLO"],
|
||||
|
||||
@@ -2,6 +2,7 @@ use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use cargo_lock::Lockfile;
|
||||
@@ -383,12 +384,27 @@ fn generate_version_info(out_dir: &Path) {
|
||||
.find(|p| p.name.as_str() == "acadrust")
|
||||
.expect("acadrust package in Cargo.lock");
|
||||
|
||||
let rustc = env::var_os("RUSTC").unwrap_or_else(|| "rustc".into());
|
||||
let rustc_version = Command::new(rustc)
|
||||
.arg("--version")
|
||||
.output()
|
||||
.ok()
|
||||
.and_then(|o| {
|
||||
if o.status.success() {
|
||||
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let info = serde_json::json!({
|
||||
"ocs_version": ocs.version.to_string(),
|
||||
"ocs_plugin_api_version": env!("CARGO_PKG_VERSION"),
|
||||
"acadrust_version": acadrust.version.to_string(),
|
||||
"acadrust_source": acadrust.source.as_ref().map(|s| s.to_string()),
|
||||
"api_version": 4,
|
||||
"rustc_version": rustc_version,
|
||||
"api_version": 5,
|
||||
"api_version_min_supported": 2,
|
||||
"build_timestamp": build_timestamp,
|
||||
});
|
||||
|
||||
@@ -299,6 +299,14 @@ pub trait BuiltinPlugin: Send + Sync {
|
||||
/// the V4 runner guarantees it is only called for plugins that report
|
||||
/// API major 4 or newer.
|
||||
fn on_notification(&mut self, _command_id: Option<u64>, _notification: HostNotification) {}
|
||||
|
||||
/// Lifecycle callback invoked once after the runner connects. The plugin
|
||||
/// receives the active document tab's `HostApi` so it can set up worker
|
||||
/// threads or cache `plugin_request_sender()` before any user command runs.
|
||||
///
|
||||
/// Added in API v5. The runner only calls this for plugins that report API
|
||||
/// major 5 or newer.
|
||||
fn on_load(&mut self, _host: &mut dyn HostApi) {}
|
||||
}
|
||||
|
||||
/// A point-driven interactive command a plugin starts via
|
||||
|
||||
@@ -17,8 +17,9 @@
|
||||
/// `document()` / `document_mut()` to local cached copies for out-of-process
|
||||
/// plugins and appends `document_reader` / `document_view` at the end of the
|
||||
/// vtable so API v2 plugins keep working. v4 adds full-duplex notifications on
|
||||
/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched.
|
||||
pub const API_VERSION: u32 = 4;
|
||||
/// a multiplexed socket while leaving the V2/V3 ABI and protocol untouched. v5
|
||||
/// adds the `BuiltinPlugin::on_load` lifecycle callback.
|
||||
pub const API_VERSION: u32 = 5;
|
||||
|
||||
/// Oldest plugin API major the current host still loads. This keeps previously
|
||||
/// compiled cdylibs usable as long as their vtable layout is a prefix of the
|
||||
@@ -26,9 +27,8 @@ pub const API_VERSION: u32 = 4;
|
||||
pub const API_VERSION_MIN_SUPPORTED: u32 = 2;
|
||||
|
||||
/// Environment variable that caps the API major accepted by the host at
|
||||
/// runtime. Set to `3` to disable V4 plugins while keeping V2/V3 plugins
|
||||
/// working; set to `2` for V2-only mode. The same mechanism will apply to
|
||||
/// future API majors.
|
||||
/// runtime. Set to `4` to disable V5 plugins, `3` to disable V4, or `2` for
|
||||
/// V2-only mode.
|
||||
pub const MAX_API_VERSION_ENV: &str = "OCS_PLUGIN_MAX_API_VERSION";
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
|
||||
@@ -56,15 +56,33 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box<dyn std::err
|
||||
|
||||
if version >= 4 {
|
||||
let client = V4Client::connect_handshake(socket_name, &token)?;
|
||||
if version >= 5 {
|
||||
invoke_on_load(&mut *plugin, &client, &interactive)?;
|
||||
}
|
||||
run_v4(&mut *plugin, &interactive, client)
|
||||
} else {
|
||||
let client = IpcClient::connect(socket_name)?;
|
||||
eprintln!("[runner] connected to host");
|
||||
client.send_handshake(&token)?;
|
||||
// V2/V3 plugins receive HostApi only during dispatch, so on_load is not
|
||||
// meaningful there; skip it to keep the old protocol unchanged.
|
||||
run_v3(&*plugin, &interactive, client)
|
||||
}
|
||||
}
|
||||
|
||||
fn invoke_on_load(
|
||||
plugin: &mut dyn BuiltinPlugin,
|
||||
client: &V4Client,
|
||||
interactive: &InteractiveRegistry,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut proxy = client.plugin_host_api(0, interactive.clone());
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
plugin.on_load(&mut proxy)
|
||||
}))
|
||||
.map_err(|_| std::io::Error::other("plugin on_load() panicked"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_v3(
|
||||
plugin: &dyn BuiltinPlugin,
|
||||
interactive: &InteractiveRegistry,
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
//! `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.
|
||||
//!
|
||||
//! The rustc version string is also recorded because Rust has no stable ABI:
|
||||
//! a plugin built with a different compiler than the host can pass simple calls
|
||||
//! and then crash the runner on compound types. For API v4 and later the host
|
||||
//! can compare the plugin's declared rustc with its own via
|
||||
//! [`rustc_versions_compatible`].
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
@@ -26,6 +32,7 @@ pub fn get_embedded_version_info_json() -> &'static str {
|
||||
#[derive(Debug, Clone)]
|
||||
struct VersionInfo {
|
||||
acadrust_source: String,
|
||||
rustc_version: String,
|
||||
}
|
||||
|
||||
pub const ACADRUST_GATE_API_VERSION: u32 = 4;
|
||||
@@ -48,6 +55,8 @@ fn parsed_version_info() -> &'static VersionInfo {
|
||||
INFO.get_or_init(|| VersionInfo {
|
||||
acadrust_source: json_string_value(EMBEDDED_VERSION_INFO_JSON, "acadrust_source")
|
||||
.unwrap_or_default(),
|
||||
rustc_version: json_string_value(EMBEDDED_VERSION_INFO_JSON, "rustc_version")
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,6 +65,30 @@ pub fn host_acadrust_source() -> &'static str {
|
||||
&parsed_version_info().acadrust_source
|
||||
}
|
||||
|
||||
/// Returns the host's `rustc --version` output.
|
||||
pub fn host_rustc_version() -> &'static str {
|
||||
&parsed_version_info().rustc_version
|
||||
}
|
||||
|
||||
/// Returns whether two `rustc --version` outputs are considered compatible.
|
||||
///
|
||||
/// The full version line (e.g. "rustc 1.98.0 (hash date)") is compared
|
||||
/// token-by-token after normalising whitespace. This is intentionally strict:
|
||||
/// Rust has no stable ABI, so even the same nominal compiler built in different
|
||||
/// environments can produce incompatible layouts.
|
||||
pub fn rustc_versions_compatible(a: &str, b: &str) -> bool {
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let a_tokens: Vec<&str> = a.split_whitespace().collect();
|
||||
let b_tokens: Vec<&str> = b.split_whitespace().collect();
|
||||
a_tokens.len() == b_tokens.len()
|
||||
&& a_tokens
|
||||
.iter()
|
||||
.zip(b_tokens)
|
||||
.all(|(a, b)| a.eq_ignore_ascii_case(b))
|
||||
}
|
||||
|
||||
/// Extracts the full git commit hash from a Cargo source.
|
||||
pub fn acadrust_source_hash(source: &str) -> Option<&str> {
|
||||
let hash = source.rsplit('#').next()?;
|
||||
@@ -85,6 +118,7 @@ mod tests {
|
||||
let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON");
|
||||
assert!(value.get("ocs_version").is_some());
|
||||
assert!(value.get("acadrust_source").is_some());
|
||||
assert!(value.get("rustc_version").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -96,6 +130,7 @@ mod tests {
|
||||
"ocs_plugin_api_version",
|
||||
"acadrust_version",
|
||||
"acadrust_source",
|
||||
"rustc_version",
|
||||
"api_version",
|
||||
"api_version_min_supported",
|
||||
"build_timestamp",
|
||||
@@ -112,7 +147,7 @@ mod tests {
|
||||
value["ocs_plugin_api_version"].as_str(),
|
||||
Some(env!("CARGO_PKG_VERSION"))
|
||||
);
|
||||
assert_eq!(value["api_version"].as_i64(), Some(4));
|
||||
assert_eq!(value["api_version"].as_i64(), Some(5));
|
||||
assert_eq!(value["api_version_min_supported"].as_i64(), Some(2));
|
||||
|
||||
// acadrust_version should have a patch component (e.g. "0.4.0").
|
||||
@@ -125,6 +160,45 @@ mod tests {
|
||||
assert!(!host_acadrust_source().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_rustc_version_is_non_empty() {
|
||||
assert!(!host_rustc_version().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rustc_version_comparison_is_strict() {
|
||||
assert!(rustc_versions_compatible(
|
||||
"rustc 1.98.0 (abc123 2026-01-01)",
|
||||
"rustc 1.98.0 (abc123 2026-01-01)"
|
||||
));
|
||||
assert!(!rustc_versions_compatible(
|
||||
"rustc 1.96.0 (def456 2025-11-01)",
|
||||
"rustc 1.98.0 (abc123 2026-01-01)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rustc_version_comparison_normalises_whitespace() {
|
||||
assert!(rustc_versions_compatible(
|
||||
"rustc 1.98.0 (abc123 2026-01-01)",
|
||||
"rustc 1.98.0 (abc123 2026-01-01)"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rustc_version_comparison_rejects_empty() {
|
||||
assert!(!rustc_versions_compatible("", "rustc 1.98.0"));
|
||||
assert!(!rustc_versions_compatible("rustc 1.98.0", ""));
|
||||
assert!(!rustc_versions_compatible("", ""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rustc_version_comparison_rejects_collapsed_tokens() {
|
||||
// A concatenated version must not accidentally match a spaced one.
|
||||
assert!(!rustc_versions_compatible("rustc 1.98.0", "rustc1.98.0"));
|
||||
assert!(!rustc_versions_compatible("rustc1.98.0", "rustc 1.98.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dependency_gate_starts_at_api_v4() {
|
||||
assert!(!uses_acadrust_gate(3));
|
||||
|
||||
Reference in new issue
Block a user