use anyhow::{Context, Result, anyhow};
use hashbrown::HashMap;
use libloading::{Library, Symbol};
use serde::{Deserialize, Serialize};
use std::ffi::{CStr, CString};
use std::path::{Path, PathBuf};
use std::ptr::NonNull;
use std::sync::Mutex;
use tracing::{debug, info, warn};
pub const PLUGIN_ABI_VERSION: u32 = 1;
use std::os::raw::c_char;
type PluginVersionFn = unsafe extern "C" fn() -> u32;
type PluginMetadataFn = unsafe extern "C" fn() -> *const c_char;
type PluginExecuteFn = unsafe extern "C" fn(*const c_char) -> *const c_char;
type PluginFreeStringFn = unsafe extern "C" fn(*const c_char);
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginContext {
pub input: HashMap<String, serde_json::Value>,
pub workspace_root: Option<String>,
pub config: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginResult {
pub success: bool,
pub output: HashMap<String, serde_json::Value>,
pub error: Option<String>,
pub files: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PluginMetadata {
pub name: String,
pub description: String,
pub version: String,
pub author: Option<String>,
pub abi_version: u32,
pub when_to_use: Option<String>,
pub when_not_to_use: Option<String>,
pub allowed_tools: Option<Vec<String>>,
#[serde(default)]
pub thread_safe: bool,
}
#[repr(C)]
#[derive(Debug, Clone)]
pub struct PluginMetadataFFI {
pub json_ptr: *const c_char,
}
#[repr(C)]
pub struct PluginResultFFI {
pub json_ptr: *const c_char,
}
pub trait NativePluginTrait: Send + Sync + std::fmt::Debug {
fn metadata(&self) -> &PluginMetadata;
fn path(&self) -> &Path;
fn execute(&self, ctx: &PluginContext) -> Result<PluginResult>;
}
pub struct NativePlugin {
_library: Library,
metadata: PluginMetadata,
path: PathBuf,
execute_fn: PluginExecuteFn,
free_string_fn: Option<PluginFreeStringFn>,
execution_lock: Mutex<()>,
thread_safe: bool,
}
fn ensure_non_null_c_string_ptr(
ptr: *const c_char,
context: &'static str,
) -> Result<NonNull<c_char>> {
NonNull::new(ptr.cast_mut()).ok_or_else(|| anyhow!("{context} returned null pointer"))
}
fn decode_plugin_c_string(
ptr: NonNull<c_char>,
free_string_fn: Option<PluginFreeStringFn>,
utf8_error_context: &'static str,
) -> Result<String> {
let raw_ptr = ptr.as_ptr() as *const c_char;
let decoded = unsafe { CStr::from_ptr(raw_ptr) }
.to_str()
.context(utf8_error_context)
.map(str::to_owned);
if let Some(free_fn) = free_string_fn {
unsafe { free_fn(raw_ptr) };
}
decoded
}
impl std::fmt::Debug for NativePlugin {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("NativePlugin")
.field("metadata", &self.metadata)
.field("path", &self.path)
.finish()
}
}
fn canonicalize_existing_path(path: &Path, label: &str) -> Result<PathBuf> {
path.canonicalize()
.with_context(|| format!("Failed to resolve {label} '{}'", path.display()))
}
fn normalize_trusted_dir(path: PathBuf) -> PathBuf {
canonicalize_existing_path(&path, "trusted plugin directory").unwrap_or_else(|_| {
if path.is_absolute() {
path
} else {
std::env::current_dir()
.map(|cwd| cwd.join(&path))
.unwrap_or(path)
}
})
}
impl NativePlugin {
pub fn new(library: Library, path: PathBuf) -> Result<Self> {
let version_fn: Symbol<PluginVersionFn> = unsafe {
library
.get(b"vtcode_plugin_version\0")
.context("Failed to load vtcode_plugin_version symbol")?
};
let abi_version = unsafe { version_fn() };
if abi_version != PLUGIN_ABI_VERSION {
return Err(anyhow!(
"Plugin ABI version mismatch: expected {}, got {}",
PLUGIN_ABI_VERSION,
abi_version
));
}
let free_string_fn = unsafe {
library
.get::<PluginFreeStringFn>(b"vtcode_plugin_free_string\0")
.map(|symbol| *symbol)
.ok()
};
let metadata_fn: Symbol<PluginMetadataFn> = unsafe {
library
.get(b"vtcode_plugin_metadata\0")
.context("Failed to load vtcode_plugin_metadata symbol")?
};
let metadata_ptr =
ensure_non_null_c_string_ptr(unsafe { metadata_fn() }, "Plugin metadata function")?;
let metadata_json = decode_plugin_c_string(
metadata_ptr,
free_string_fn,
"Plugin metadata is not valid UTF-8",
)?;
let metadata: PluginMetadata =
serde_json::from_str(&metadata_json).context("Failed to parse plugin metadata JSON")?;
let execute_fn: Symbol<PluginExecuteFn> = unsafe {
library
.get(b"vtcode_plugin_execute\0")
.context("Failed to load vtcode_plugin_execute symbol")?
};
let execute_fn_ptr = *execute_fn;
Ok(Self {
_library: library,
metadata: metadata.clone(), path,
execute_fn: execute_fn_ptr,
free_string_fn,
execution_lock: Mutex::new(()),
thread_safe: metadata.thread_safe, })
}
pub fn execute(&self, ctx: &PluginContext) -> Result<PluginResult> {
let input_json =
serde_json::to_string(ctx).context("Failed to serialize plugin context")?;
let input_cstr =
CString::new(input_json).context("Plugin context contains internal null bytes")?;
if !self.thread_safe {
let _execution_guard = self
.execution_lock
.lock()
.map_err(|_| anyhow!("native plugin execution lock poisoned"))?;
self.execute_ffi(input_cstr)
} else {
self.execute_ffi(input_cstr)
}
}
fn execute_ffi(&self, input_cstr: CString) -> Result<PluginResult> {
let result_ptr = ensure_non_null_c_string_ptr(
unsafe { (self.execute_fn)(input_cstr.as_ptr()) },
"Plugin execute function",
)?;
let result_json = decode_plugin_c_string(
result_ptr,
self.free_string_fn,
"Plugin result is not valid UTF-8",
)?;
let result: PluginResult =
serde_json::from_str(&result_json).context("Failed to parse plugin result JSON")?;
Ok(result)
}
}
impl NativePluginTrait for NativePlugin {
fn metadata(&self) -> &PluginMetadata {
&self.metadata
}
fn path(&self) -> &Path {
&self.path
}
fn execute(&self, ctx: &PluginContext) -> Result<PluginResult> {
self.execute(ctx)
}
}
pub struct PluginLoader {
trusted_dirs: Vec<PathBuf>,
}
impl PluginLoader {
pub fn new() -> Self {
Self {
trusted_dirs: Vec::new(),
}
}
pub fn add_trusted_dir(&mut self, path: PathBuf) -> &mut Self {
let path = normalize_trusted_dir(path);
if !self.trusted_dirs.contains(&path) {
self.trusted_dirs.push(path);
}
self
}
pub fn trusted_dirs(&self) -> &[PathBuf] {
&self.trusted_dirs
}
pub fn load_plugin(&self, plugin_path: &Path) -> Result<Box<dyn NativePluginTrait>> {
debug!("Loading native plugin from {:?}", plugin_path);
let plugin_path = self.ensure_trusted_path(plugin_path, "Plugin path")?;
let lib_path = self.find_library_file(&plugin_path)?;
let lib_path = self.ensure_trusted_path(&lib_path, "Plugin library path")?;
let library = unsafe { Library::new(&lib_path) }
.with_context(|| format!("Failed to load dynamic library at {:?}", lib_path))?;
let plugin = NativePlugin::new(library, plugin_path.clone())?;
info!(
"Loaded native plugin '{}' v{} from {:?}",
plugin.metadata.name, plugin.metadata.version, plugin_path
);
Ok(Box::new(plugin))
}
pub fn discover_plugins(&self) -> Result<Vec<Box<dyn NativePluginTrait>>> {
let mut plugins = Vec::new();
for dir in &self.trusted_dirs {
if !dir.exists() {
continue;
}
match self.discover_plugins_in_dir(dir) {
Ok(mut dir_plugins) => plugins.append(&mut dir_plugins),
Err(e) => {
warn!("Failed to discover plugins in {:?}: {}", dir, e);
}
}
}
Ok(plugins)
}
fn is_in_trusted_dir(&self, path: &Path) -> bool {
self.trusted_dirs.iter().any(|dir| path.starts_with(dir))
}
fn ensure_trusted_path(&self, path: &Path, label: &str) -> Result<PathBuf> {
let path = canonicalize_existing_path(path, label)?;
if self.is_in_trusted_dir(&path) {
Ok(path)
} else {
Err(anyhow!("{label} {:?} is not in a trusted directory", path))
}
}
fn find_library_file(&self, plugin_dir: &Path) -> Result<PathBuf> {
if !plugin_dir.is_dir() {
return Err(anyhow!("Plugin path is not a directory"));
}
let metadata_path = plugin_dir.join("plugin.json");
if !metadata_path.exists() {
return Err(anyhow!("No plugin.json found in {:?}", plugin_dir));
}
let lib_name = self.get_library_name_from_metadata(&metadata_path)?;
let lib_path = plugin_dir.join(&lib_name);
if lib_path.exists() {
return Ok(lib_path);
}
let alternatives = self.get_alternative_library_names(&lib_name);
for alt in alternatives {
let alt_path = plugin_dir.join(alt);
if alt_path.exists() {
return Ok(alt_path);
}
}
Err(anyhow!(
"No dynamic library found in {:?}. Expected one of: {}, or alternatives",
plugin_dir,
lib_name
))
}
fn get_library_name_from_metadata(&self, metadata_path: &Path) -> Result<String> {
let metadata_content =
std::fs::read_to_string(metadata_path).context("Failed to read plugin metadata")?;
let metadata: serde_json::Value =
serde_json::from_str(&metadata_content).context("Invalid plugin metadata JSON")?;
let name = metadata["name"]
.as_str()
.ok_or_else(|| anyhow!("Plugin metadata missing 'name' field"))?;
Ok(self.library_filename(name))
}
fn get_alternative_library_names(&self, base_name: &str) -> Vec<String> {
let mut alternatives = Vec::new();
if let Some(stripped) = base_name.strip_prefix("lib") {
alternatives.push(stripped.to_string());
} else {
alternatives.push(format!("lib{}", base_name));
}
let base = base_name.strip_prefix("lib").unwrap_or(base_name);
#[cfg(target_os = "macos")]
{
alternatives.push(format!("{}.dylib", base));
alternatives.push(format!("lib{}.dylib", base));
}
#[cfg(target_os = "linux")]
{
alternatives.push(format!("{}.so", base));
alternatives.push(format!("lib{}.so", base));
}
#[cfg(target_os = "windows")]
{
alternatives.push(format!("{}.dll", base));
alternatives.push(format!("lib{}.dll", base));
}
alternatives
}
fn discover_plugins_in_dir(&self, dir: &Path) -> Result<Vec<Box<dyn NativePluginTrait>>> {
let mut plugins = Vec::new();
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() && path.join("plugin.json").exists() {
match self.load_plugin(&path) {
Ok(plugin) => plugins.push(plugin),
Err(e) => {
warn!("Failed to load plugin at {:?}: {}", path, e);
}
}
}
}
Ok(plugins)
}
pub fn library_filename(&self, name: &str) -> String {
#[cfg(target_os = "macos")]
{
format!("lib{}.dylib", name)
}
#[cfg(target_os = "linux")]
{
format!("lib{}.so", name)
}
#[cfg(target_os = "windows")]
{
format!("{}.dll", name)
}
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
{
format!("lib{}", name)
}
}
}
impl Default for PluginLoader {
fn default() -> Self {
Self::new()
}
}
pub fn validate_plugin_structure(plugin_dir: &Path) -> Result<Vec<String>> {
let mut errors = Vec::new();
if !plugin_dir.join("plugin.json").exists() {
errors.push("Missing plugin.json".to_string());
}
let has_lib = std::fs::read_dir(plugin_dir)
.map(|entries| {
entries.filter_map(|e| e.ok()).any(|entry| {
let path = entry.path();
let ext = path.extension().and_then(|e| e.to_str());
matches!(ext, Some("dylib") | Some("so") | Some("dll"))
})
})
.unwrap_or(false);
if !has_lib {
errors.push("No dynamic library found (.dylib, .so, or .dll)".to_string());
}
if let Ok(content) = std::fs::read_to_string(plugin_dir.join("plugin.json")) {
if let Ok(metadata) = serde_json::from_str::<serde_json::Value>(&content) {
if metadata["name"].as_str().is_none() {
errors.push("plugin.json missing required 'name' field".to_string());
}
if metadata["description"].as_str().is_none() {
errors.push("plugin.json missing required 'description' field".to_string());
}
if metadata["version"].as_str().is_none() {
errors.push("plugin.json missing required 'version' field".to_string());
}
} else {
errors.push("Invalid JSON in plugin.json".to_string());
}
}
Ok(errors)
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::cell::Cell;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tempfile::TempDir;
thread_local! {
static TEST_FREE_WAS_CALLED: Cell<bool> = const { Cell::new(false) };
}
static TEST_EXECUTE_ACTIVE_CALLS: AtomicUsize = AtomicUsize::new(0);
static TEST_EXECUTE_MAX_CONCURRENCY: AtomicUsize = AtomicUsize::new(0);
unsafe extern "C" fn test_free_string(ptr: *const c_char) {
TEST_FREE_WAS_CALLED.with(|was_called| was_called.set(true));
if !ptr.is_null() {
let _ = unsafe { CString::from_raw(ptr as *mut c_char) };
}
}
fn create_test_plugin_dir() -> (TempDir, PathBuf) {
let temp_dir = TempDir::new().unwrap();
let plugin_dir = temp_dir.path().join("test-plugin");
std::fs::create_dir(&plugin_dir).unwrap();
(temp_dir, plugin_dir)
}
fn write_plugin_metadata(plugin_dir: &Path, name: &str) {
std::fs::write(
plugin_dir.join("plugin.json"),
format!(r#"{{"name":"{name}","description":"test","version":"1.0.0"}}"#),
)
.unwrap();
}
fn write_fake_library(plugin_dir: &Path, name: &str) -> PathBuf {
let loader = PluginLoader::new();
let library_path = plugin_dir.join(loader.library_filename(name));
std::fs::write(&library_path, b"fake-library").unwrap();
library_path
}
fn current_process_library() -> Library {
#[cfg(unix)]
{
libloading::os::unix::Library::this().into()
}
#[cfg(windows)]
{
libloading::os::windows::Library::this()
.expect("current process library")
.into()
}
}
fn update_max_concurrency(active_calls: usize) {
let mut current_max = TEST_EXECUTE_MAX_CONCURRENCY.load(Ordering::SeqCst);
while active_calls > current_max {
match TEST_EXECUTE_MAX_CONCURRENCY.compare_exchange(
current_max,
active_calls,
Ordering::SeqCst,
Ordering::SeqCst,
) {
Ok(_) => break,
Err(observed) => current_max = observed,
}
}
}
unsafe extern "C" fn test_execute_with_delay(_input: *const c_char) -> *const c_char {
let active_calls = TEST_EXECUTE_ACTIVE_CALLS.fetch_add(1, Ordering::SeqCst) + 1;
update_max_concurrency(active_calls);
std::thread::sleep(Duration::from_millis(25));
TEST_EXECUTE_ACTIVE_CALLS.fetch_sub(1, Ordering::SeqCst);
CString::new(r#"{"success":true,"output":{},"error":null,"files":[]}"#)
.unwrap()
.into_raw()
}
#[test]
fn test_validate_plugin_structure_missing_metadata() {
let (_temp_dir, plugin_dir) = create_test_plugin_dir();
let errors = validate_plugin_structure(&plugin_dir).unwrap();
assert!(errors.iter().any(|e| e.contains("plugin.json")));
}
#[test]
fn test_validate_plugin_structure_missing_library() {
let (_temp_dir, plugin_dir) = create_test_plugin_dir();
std::fs::write(
plugin_dir.join("plugin.json"),
r#"{"name": "test", "description": "test", "version": "1.0.0"}"#,
)
.unwrap();
let errors = validate_plugin_structure(&plugin_dir).unwrap();
assert!(errors.iter().any(|e| e.contains("dynamic library")));
}
#[test]
fn test_validate_plugin_structure_complete() {
let (_temp_dir, plugin_dir) = create_test_plugin_dir();
std::fs::write(
plugin_dir.join("plugin.json"),
r#"{"name": "test", "description": "test", "version": "1.0.0"}"#,
)
.unwrap();
let lib_name = if cfg!(target_os = "macos") {
"libtest.dylib"
} else if cfg!(target_os = "linux") {
"libtest.so"
} else {
"test.dll"
};
std::fs::write(plugin_dir.join(lib_name), b"fake").unwrap();
let errors = validate_plugin_structure(&plugin_dir).unwrap();
assert!(errors.is_empty());
}
#[test]
fn test_library_filename_platform() {
let loader = PluginLoader::new();
let filename = loader.library_filename("my-plugin");
#[cfg(target_os = "macos")]
assert_eq!(filename, "libmy-plugin.dylib");
#[cfg(target_os = "linux")]
assert_eq!(filename, "libmy-plugin.so");
#[cfg(target_os = "windows")]
assert_eq!(filename, "my-plugin.dll");
}
#[test]
fn test_ensure_non_null_c_string_ptr_rejects_null() {
let err = ensure_non_null_c_string_ptr(std::ptr::null::<c_char>(), "Test pointer")
.expect_err("null pointer should be rejected");
assert!(
err.to_string()
.contains("Test pointer returned null pointer")
);
}
#[test]
fn test_decode_plugin_c_string_frees_plugin_buffer() {
TEST_FREE_WAS_CALLED.with(|was_called| was_called.set(false));
let raw = CString::new("{\"ok\":true}")
.expect("valid C string")
.into_raw();
let ptr = NonNull::new(raw).expect("non-null raw pointer");
let decoded = decode_plugin_c_string(
ptr,
Some(test_free_string),
"Plugin result is not valid UTF-8",
)
.expect("valid UTF-8 payload");
assert_eq!(decoded, "{\"ok\":true}");
TEST_FREE_WAS_CALLED.with(|was_called| assert!(was_called.get()));
}
#[test]
fn test_decode_plugin_c_string_invalid_utf8_still_frees_buffer() {
TEST_FREE_WAS_CALLED.with(|was_called| was_called.set(false));
let raw = CString::from_vec_with_nul(vec![0xFF, 0x00])
.expect("valid nul-terminated C string")
.into_raw();
let ptr = NonNull::new(raw).expect("non-null raw pointer");
let err = decode_plugin_c_string(
ptr,
Some(test_free_string),
"Plugin payload is not valid UTF-8",
)
.expect_err("invalid UTF-8 should fail decoding");
assert!(
err.to_string()
.contains("Plugin payload is not valid UTF-8")
);
TEST_FREE_WAS_CALLED.with(|was_called| assert!(was_called.get()));
}
#[test]
fn test_load_plugin_rejects_dotdot_escape_from_trusted_root() {
let temp_dir = TempDir::new().unwrap();
let trusted_root = temp_dir.path().join("trusted");
let escaped_plugin_dir = temp_dir.path().join("escaped-plugin");
std::fs::create_dir(&trusted_root).unwrap();
std::fs::create_dir(&escaped_plugin_dir).unwrap();
write_plugin_metadata(&escaped_plugin_dir, "escaped");
write_fake_library(&escaped_plugin_dir, "escaped");
let escaped_path = trusted_root.join("..").join("escaped-plugin");
let mut loader = PluginLoader::new();
loader.add_trusted_dir(trusted_root);
let err = loader
.load_plugin(&escaped_path)
.expect_err("path traversal should be rejected");
assert!(err.to_string().contains("trusted directory"));
}
#[cfg(unix)]
#[test]
fn test_load_plugin_rejects_symlinked_plugin_dir_escape() {
use std::os::unix::fs::symlink;
let temp_dir = TempDir::new().unwrap();
let trusted_root = temp_dir.path().join("trusted");
let real_plugin_dir = temp_dir.path().join("external-plugin");
let symlinked_plugin_dir = trusted_root.join("linked-plugin");
std::fs::create_dir(&trusted_root).unwrap();
std::fs::create_dir(&real_plugin_dir).unwrap();
write_plugin_metadata(&real_plugin_dir, "linked");
write_fake_library(&real_plugin_dir, "linked");
symlink(&real_plugin_dir, &symlinked_plugin_dir).unwrap();
let mut loader = PluginLoader::new();
loader.add_trusted_dir(trusted_root);
let err = loader
.load_plugin(&symlinked_plugin_dir)
.expect_err("symlink escape should be rejected");
assert!(err.to_string().contains("trusted directory"));
}
#[cfg(unix)]
#[test]
fn test_load_plugin_rejects_symlinked_library_escape() {
use std::os::unix::fs::symlink;
let temp_dir = TempDir::new().unwrap();
let trusted_root = temp_dir.path().join("trusted");
let plugin_dir = trusted_root.join("plugin");
let external_dir = temp_dir.path().join("external");
std::fs::create_dir(&trusted_root).unwrap();
std::fs::create_dir(&plugin_dir).unwrap();
std::fs::create_dir(&external_dir).unwrap();
write_plugin_metadata(&plugin_dir, "escaped-lib");
let external_library = write_fake_library(&external_dir, "escaped-lib");
let linked_library = plugin_dir.join(PluginLoader::new().library_filename("escaped-lib"));
symlink(&external_library, &linked_library).unwrap();
let mut loader = PluginLoader::new();
loader.add_trusted_dir(trusted_root);
let err = loader
.load_plugin(&plugin_dir)
.expect_err("library symlink escape should be rejected");
assert!(err.to_string().contains("trusted directory"));
}
#[test]
#[serial]
fn test_native_plugin_serializes_concurrent_execution() {
TEST_EXECUTE_ACTIVE_CALLS.store(0, Ordering::SeqCst);
TEST_EXECUTE_MAX_CONCURRENCY.store(0, Ordering::SeqCst);
let plugin = Arc::new(NativePlugin {
_library: current_process_library(),
metadata: PluginMetadata {
name: "serialized".to_string(),
description: "test plugin".to_string(),
version: "1.0.0".to_string(),
author: None,
abi_version: PLUGIN_ABI_VERSION,
when_to_use: None,
when_not_to_use: None,
allowed_tools: None,
thread_safe: false,
},
path: PathBuf::from("/tmp/serialized-plugin"),
execute_fn: test_execute_with_delay,
free_string_fn: Some(test_free_string),
execution_lock: Mutex::new(()),
thread_safe: false,
});
let ctx = PluginContext {
input: HashMap::new(),
workspace_root: None,
config: HashMap::new(),
};
let handles = (0..4)
.map(|_| {
let plugin = Arc::clone(&plugin);
let ctx = ctx.clone();
std::thread::spawn(move || plugin.execute(&ctx).expect("plugin execution"))
})
.collect::<Vec<_>>();
for handle in handles {
let result = handle.join().expect("thread should complete");
assert!(result.success);
}
assert_eq!(TEST_EXECUTE_MAX_CONCURRENCY.load(Ordering::SeqCst), 1);
}
#[test]
#[serial]
fn test_native_plugin_allows_parallel_execution() {
TEST_EXECUTE_ACTIVE_CALLS.store(0, Ordering::SeqCst);
TEST_EXECUTE_MAX_CONCURRENCY.store(0, Ordering::SeqCst);
let plugin = Arc::new(NativePlugin {
_library: current_process_library(),
metadata: PluginMetadata {
name: "parallel".to_string(),
description: "reentrant test plugin".to_string(),
version: "1.0.0".to_string(),
author: None,
abi_version: PLUGIN_ABI_VERSION,
when_to_use: None,
when_not_to_use: None,
allowed_tools: None,
thread_safe: true, },
path: PathBuf::from("/tmp/parallel-plugin"),
execute_fn: test_execute_with_delay,
free_string_fn: Some(test_free_string),
execution_lock: Mutex::new(()),
thread_safe: true,
});
let ctx = PluginContext {
input: HashMap::new(),
workspace_root: None,
config: HashMap::new(),
};
let num_threads = 4;
let handles = (0..num_threads)
.map(|_| {
let plugin = Arc::clone(&plugin);
let ctx = ctx.clone();
std::thread::spawn(move || plugin.execute(&ctx).expect("parallel plugin execution"))
})
.collect::<Vec<_>>();
for handle in handles {
let result = handle.join().expect("thread join");
assert!(result.success);
}
assert!(TEST_EXECUTE_MAX_CONCURRENCY.load(Ordering::SeqCst) > 1);
}
}