From f3da9993c74b17a1d4dfbf13776ac175805fecac Mon Sep 17 00:00:00 2001 From: Miles Johnson Date: Sat, 4 Jul 2026 18:35:23 -0700 Subject: [PATCH] Add pool. --- crates/warpgate-pdk/src/tracing.rs | 6 +- crates/warpgate/src/lib.rs | 2 + crates/warpgate/src/plugin.rs | 165 +++++++++++----- crates/warpgate/src/plugin_pool.rs | 103 ++++++++++ crates/warpgate/tests/plugin_test.rs | 285 +++++++++++++++++++++++++++ plugins/Cargo.lock | 10 +- plugins/api-usage/src/lib.rs | 17 ++ 7 files changed, 536 insertions(+), 52 deletions(-) create mode 100644 crates/warpgate/src/plugin_pool.rs create mode 100644 crates/warpgate/tests/plugin_test.rs diff --git a/crates/warpgate-pdk/src/tracing.rs b/crates/warpgate-pdk/src/tracing.rs index 98580083c..cf65df62f 100644 --- a/crates/warpgate-pdk/src/tracing.rs +++ b/crates/warpgate-pdk/src/tracing.rs @@ -122,6 +122,8 @@ pub fn initialize_tracing() { /// Initialize `tracing` events to be captured by Extism, with the provided options. pub fn initialize_tracing_with_options(options: WarpgateTracingOptions) { - set_global_default(Registry::default().with(WarpgateToExtismLayer { options })) - .expect("Global tracing subscriber has already been set!") + // A plugin instance may invoke an entry point function multiple times + // across its lifetime, so ignore duplicate initializations, otherwise + // the plugin panics with an `unreachable` trap. + let _ = set_global_default(Registry::default().with(WarpgateToExtismLayer { options })); } diff --git a/crates/warpgate/src/lib.rs b/crates/warpgate/src/lib.rs index a1da3cc0d..b06fe5268 100644 --- a/crates/warpgate/src/lib.rs +++ b/crates/warpgate/src/lib.rs @@ -5,6 +5,7 @@ mod loader; mod loader_error; mod plugin; mod plugin_error; +mod plugin_pool; mod protocols; mod registry; pub mod test_utils; @@ -15,6 +16,7 @@ pub use loader::*; pub use loader_error::*; pub use plugin::*; pub use plugin_error::*; +pub use plugin_pool::*; pub use registry::*; pub use extism::{Manifest as PluginManifest, Wasm}; diff --git a/crates/warpgate/src/plugin.rs b/crates/warpgate/src/plugin.rs index 4e5c47b43..dc3680a73 100644 --- a/crates/warpgate/src/plugin.rs +++ b/crates/warpgate/src/plugin.rs @@ -1,6 +1,7 @@ use crate::helpers::{from_virtual_path, sort_virtual_paths, to_virtual_path}; use crate::plugin_error::WarpgatePluginError; -use extism::{Error, Function, Manifest, Plugin}; +use crate::plugin_pool::PluginInstancePool; +use extism::{Error, Function, Manifest, Plugin, PluginBuilder}; use scc::hash_map::Entry; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; @@ -14,12 +15,12 @@ use std::path::{Path, PathBuf}; use std::sync::{Arc, OnceLock}; use std::time::Instant; use system_env::{SystemArch, SystemLibc, SystemOS}; -use tokio::sync::RwLock; -use tokio::task::block_in_place; +use tokio::sync::OnceCell; +use tokio::task::spawn_blocking; use tracing::{instrument, trace}; use warpgate_api::{HostEnvironment, Id, VirtualPath}; -fn is_incompatible_runtime(error: &Error) -> bool { +pub(crate) fn is_incompatible_runtime(error: &Error) -> bool { let check = |message: String| { // unknown import: `env::exec_command` has not been defined message.contains("unknown import") && message.contains("env::") @@ -34,6 +35,30 @@ fn is_incompatible_runtime(error: &Error) -> bool { check(error.to_string()) } +pub(crate) fn map_container_error(id: &Id, error: Error) -> WarpgatePluginError { + if is_incompatible_runtime(&error) { + WarpgatePluginError::IncompatibleRuntime { id: id.to_owned() } + } else { + WarpgatePluginError::FailedContainer { + id: id.to_owned(), + error: Box::new(error), + } + } +} + +// The trap types are not exposed through Extism's public API, +// so we must detect them based on known error messages. +pub(crate) fn is_trap_error(error: &Error) -> bool { + error.chain().any(|cause| { + let message = cause.to_string(); + + message.contains("wasm trap") + || message.contains("out of fuel") + || message == "timeout" + || message == "oom" + }) +} + /// Inject our default configuration into the provided plugin manifest. /// This will set `plugin_id` and `host_environment` for use within PDKs. #[instrument(skip(manifest))] @@ -81,15 +106,22 @@ pub type OnCallFn = Arc, Option<&str>) + Send + Sync>; /// A container around Extism's [`Plugin`] and [`Manifest`] types that provides convenience /// methods for calling and caching functions from the WASM plugin. It also provides /// additional methods for easily working with WASI and virtual paths. +/// +/// The WASM file is compiled once, while plugin instances are created on demand +/// and pooled, defaulting to a single instance, which matches the historical +/// behavior. Consumers can allow parallel execution per plugin with the +/// `plugin_instances` manifest configuration key, but only for plugins that do +/// not rely on in-memory guest state (like Extism variables) persisting across +/// separate calls, as each call may then execute on a different instance. pub struct PluginContainer { pub id: Id, pub manifest: Manifest, pub virtual_paths: Vec<(PathBuf, PathBuf)>, debug_call: bool, - func_cache: Arc>>, + func_cache: Arc>>>>, on_call_func: Arc>, - plugin: Arc>, + instances: Arc, } impl PluginContainer { @@ -102,20 +134,21 @@ impl PluginContainer { ) -> Result { trace!(id = id.as_str(), "Creating plugin container"); - let plugin = Plugin::new(&manifest, functions, true).map_err(|error| { - if is_incompatible_runtime(&error) { - WarpgatePluginError::IncompatibleRuntime { id: id.clone() } - } else { - WarpgatePluginError::FailedContainer { - id: id.clone(), - error: Box::new(error), - } - } - })?; + let compiled = PluginBuilder::new(&manifest) + .with_functions(functions) + .with_wasi(true) + .compile() + .map_err(|error| map_container_error(&id, error))?; + + // Create the first instance eagerly so that runtime incompatibilities, + // like missing host functions, error at registration instead of on + // the first call. + let instance = Plugin::new_from_compiled(&compiled) + .map_err(|error| map_container_error(&id, error))?; trace!( id = id.as_str(), - plugin = plugin.id.to_string(), + plugin = instance.id.to_string(), "Created plugin container", ); @@ -129,10 +162,12 @@ impl PluginContainer { sort_virtual_paths(&mut virtual_paths); + let instances = Arc::new(PluginInstancePool::new(compiled, &manifest, vec![instance])); + Ok(PluginContainer { virtual_paths, manifest, - plugin: Arc::new(RwLock::new(plugin)), + instances, id, func_cache: Arc::new(scc::HashMap::new()), on_call_func: Arc::new(OnceLock::new()), @@ -164,7 +199,9 @@ impl PluginContainer { } /// Call a function on the plugin with the given input and cache the output - /// before returning it. Subsequent calls with the same input will read from the cache. + /// before returning it. Subsequent calls with the same input will read from + /// the cache, while concurrent calls with the same input will only execute + /// the function once (single-flight). #[instrument(skip(self))] pub async fn cache_func_with( &self, @@ -180,19 +217,19 @@ impl PluginContainer { let input = self.format_input(func, input)?; let cache_key = format!("{func}-{}", hash::base64::from_bytes(&input)); - match self.func_cache.entry_async(cache_key).await { - // Check if cache exists already - Entry::Occupied(entry) => self.parse_output(func, entry.get()), - // Otherwise call the function and cache the result - Entry::Vacant(entry) => { - let data = self.call(func, input).await?; - let output: O = self.parse_output(func, &data)?; + // Insert the cell synchronously so that the map's shard lock is never + // held while the function is being called (which may take minutes). + // The cell itself provides the single-flight semantics. + let cell = match self.func_cache.entry_async(cache_key).await { + Entry::Occupied(entry) => Arc::clone(entry.get()), + Entry::Vacant(entry) => Arc::clone(entry.insert_entry(Arc::new(OnceCell::new())).get()), + }; - entry.insert_entry(data); + // If the call fails, the cell remains empty, so that + // subsequent calls can attempt the function again. + let data = cell.get_or_try_init(|| self.call(func, input)).await?; - Ok(output) - } - } + self.parse_output(func, data) } /// Call a function on the plugin with no input and return the output. @@ -243,14 +280,25 @@ impl PluginContainer { pub async fn has_func(&self, func: impl AsRef + Debug) -> bool { let func = func.as_ref(); - match self.func_cache.entry_async(func.into()).await { - Entry::Occupied(entry) => entry.get()[0] == 1, - Entry::Vacant(entry) => { - let exists = self.plugin.read().await.function_exists(func); - entry.insert_entry(vec![exists as u8]); - exists - } - } + let cell = match self.func_cache.entry_async(func.into()).await { + Entry::Occupied(entry) => Arc::clone(entry.get()), + Entry::Vacant(entry) => Arc::clone(entry.insert_entry(Arc::new(OnceCell::new())).get()), + }; + + cell.get_or_try_init(|| async { + let (instance, permit) = self.instances.acquire(&self.id).await?; + + // This only inspects module metadata and does not execute + // any guest code, so the instance is always reusable. + let exists = instance.function_exists(func); + + self.instances.restore(instance, permit, true); + + Ok::<_, WarpgatePluginError>(vec![exists as u8]) + }) + .await + .map(|data| data[0] == 1) + .unwrap_or(false) } /// Convert the provided virtual guest path to an absolute host path. @@ -271,13 +319,16 @@ impl PluginContainer { func: &str, input: impl AsRef<[u8]>, ) -> Result, WarpgatePluginError> { - let mut instance = self.plugin.write().await; - let input = input.as_ref(); - let input_string = String::from_utf8_lossy(input); - let uuid = instance.id.to_string(); // Copy + let input = input.as_ref().to_vec(); + let input_string = String::from_utf8_lossy(&input).into_owned(); let instant = Instant::now(); let truncate_size = 5000; + // Check out an instance from the pool, waiting when all + // instances are busy with other calls + let (mut instance, permit) = self.instances.acquire(&self.id).await?; + let uuid = instance.id.to_string(); // Copy + trace!( id = self.id.as_str(), plugin = &uuid, @@ -294,7 +345,31 @@ impl PluginContainer { callback(func, Some(&input_string), None); } - let output = block_in_place(|| instance.call(func, input)).map_err(|error| { + let func_name = func.to_owned(); + let pool = Arc::clone(&self.instances); + + // Guest calls block the current thread, so execute them on the + // blocking pool. The instance is restored within the closure so + // that it is never lost if this future is dropped mid-call. + let output = spawn_blocking(move || { + let result = instance + .call::<&[u8], &[u8]>(&func_name, &input) + .map(|output| output.to_vec()); + + // Keep the instance when the call succeeded or a clean error + // was returned, and only discard it when the guest trapped + let reusable = match &result { + Ok(_) => true, + Err(error) => !is_trap_error(error), + }; + + pool.restore(instance, permit, reusable); + + result + }) + .await + .unwrap_or_else(|error| Err(Error::msg(error.to_string()))) + .map_err(|error| { if is_incompatible_runtime(&error) { return WarpgatePluginError::IncompatibleRuntime { id: self.id.clone(), @@ -329,7 +404,7 @@ impl PluginContainer { } })?; - let output_string = String::from_utf8_lossy(output); + let output_string = String::from_utf8_lossy(&output); trace!( id = self.id.as_str(), @@ -348,7 +423,7 @@ impl PluginContainer { callback(func, None, Some(&output_string)); } - Ok(output.to_vec()) + Ok(output) } fn format_input( diff --git a/crates/warpgate/src/plugin_pool.rs b/crates/warpgate/src/plugin_pool.rs new file mode 100644 index 000000000..c1d8308c5 --- /dev/null +++ b/crates/warpgate/src/plugin_pool.rs @@ -0,0 +1,103 @@ +use crate::plugin::map_container_error; +use crate::plugin_error::WarpgatePluginError; +use extism::{CompiledPlugin, Manifest, Plugin}; +use std::env; +use std::sync::{Arc, Mutex}; +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use warpgate_api::Id; + +/// The manifest configuration key that plugin consumers can define to +/// allow multiple instances of the plugin to execute calls in parallel. +pub const MAX_INSTANCES_CONFIG_KEY: &str = "plugin_instances"; + +fn determine_max_instances(manifest: &Manifest) -> usize { + // Environment variable takes precedence as a global override, + // primarily for debugging and testing purposes + if let Ok(value) = env::var("WARPGATE_PLUGIN_INSTANCES") + && let Ok(count) = value.parse::() + && count > 0 + { + return count; + } + + // Otherwise the host application must opt-in each plugin, + // as it requires the plugin to not rely on cross-call state + if let Some(value) = manifest.config.get(MAX_INSTANCES_CONFIG_KEY) + && let Ok(count) = value.parse::() + && count > 0 + { + return count.min(16); + } + + // Extism variables (and other guest state) are stored per instance, + // and many plugins use them as a cross-call cache, so a single + // instance must be the default to preserve existing semantics! + 1 +} + +/// A bounded pool of instances created from a single pre-compiled plugin. +/// Each guest call checks out an instance exclusively, so concurrent calls +/// execute in parallel across instances instead of serializing on one. +/// +/// The pool defaults to a single instance, which matches the historical +/// behavior of one long-lived instance per plugin. Consumers can allow +/// parallel execution per plugin with the `plugin_instances` manifest +/// configuration key, but only for plugins that do not rely on in-memory +/// guest state (like Extism variables) persisting across separate calls, +/// as each call may then execute on a different instance. +pub struct PluginInstancePool { + compiled: CompiledPlugin, + idle: Mutex>, + limiter: Arc, +} + +impl PluginInstancePool { + /// Create a new pool from the pre-compiled plugin, seeded with the + /// provided instances. The maximum number of live instances is defined + /// by the manifest configuration, or the `WARPGATE_PLUGIN_INSTANCES` + /// environment variable, and defaults to 1. + pub fn new(compiled: CompiledPlugin, manifest: &Manifest, instances: Vec) -> Self { + Self { + compiled, + idle: Mutex::new(instances), + limiter: Arc::new(Semaphore::new(determine_max_instances(manifest))), + } + } + + /// Check out an idle instance, or create a new one from the pre-compiled + /// plugin (cheap, as no compilation occurs). Waits when the maximum + /// number of instances are all busy. + pub async fn acquire( + &self, + id: &Id, + ) -> Result<(Plugin, OwnedSemaphorePermit), WarpgatePluginError> { + let permit = Arc::clone(&self.limiter) + .acquire_owned() + .await + .expect("Plugin instance limiter has been closed!"); + + let instance = self.idle.lock().unwrap().pop(); + + let instance = match instance { + Some(instance) => instance, + None => Plugin::new_from_compiled(&self.compiled) + .map_err(|error| map_container_error(id, error))?, + }; + + Ok((instance, permit)) + } + + /// Return an instance to the pool once its call has finished. Instances + /// that trapped during their call are discarded instead of reused, since + /// the guest's internal state can no longer be trusted (memory may be + /// corrupted), and re-creating an instance is cheap. Clean guest errors + /// keep the instance, as plugins return errors during normal operation, + /// and their state (like Extism variables) must persist across calls. + pub fn restore(&self, instance: Plugin, permit: OwnedSemaphorePermit, reusable: bool) { + if reusable { + self.idle.lock().unwrap().push(instance); + } + + drop(permit); + } +} diff --git a/crates/warpgate/tests/plugin_test.rs b/crates/warpgate/tests/plugin_test.rs new file mode 100644 index 000000000..6336cff4b --- /dev/null +++ b/crates/warpgate/tests/plugin_test.rs @@ -0,0 +1,285 @@ +use serde_json::{Value, json}; +use starbase_sandbox::{Sandbox, create_empty_sandbox}; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::task::JoinSet; +use warpgate::host::{HostData, create_host_functions}; +use warpgate::{ + Id, MAX_INSTANCES_CONFIG_KEY, PluginContainer, PluginLocator, PluginManifest, Wasm, + create_http_client, find_debug_locator, inject_default_manifest_config, +}; + +fn create_container_for( + sandbox: &Path, + wasm_name: &str, + max_instances: Option<&str>, +) -> PluginContainer { + let id = Id::raw("moonstone"); + + let wasm_file = find_debug_locator(wasm_name) + .and_then(|locator| match locator { + PluginLocator::File(file) => file.path.clone(), + _ => None, + }) + .expect("Test plugins not available. Run `just build-wasm` to build them!"); + + let mut manifest = PluginManifest::new([Wasm::file(wasm_file)]); + manifest.timeout_ms = None; + + if let Some(max) = max_instances { + manifest + .config + .insert(MAX_INSTANCES_CONFIG_KEY.into(), max.into()); + } + + inject_default_manifest_config(&id, &sandbox.join("home"), &mut manifest).unwrap(); + + let functions = create_host_functions(HostData { + cache_dir: sandbox.join("cache"), + http_client: Arc::new(create_http_client().unwrap()), + virtual_paths: vec![], + working_dir: sandbox.to_path_buf(), + }); + + PluginContainer::new(id, manifest, functions).unwrap() +} + +fn create_sandboxed_container() -> (Sandbox, PluginContainer) { + let sandbox = create_empty_sandbox(); + + // Opt-in to parallel execution to exercise the instance pool + let container = create_container_for(sandbox.path(), "proto_mocked_tool", Some("4")); + + (sandbox, container) +} + +fn create_context() -> Value { + json!({ + "temp_dir": "/temp", + "tool_dir": "/tool", + "working_dir": "/cwd", + }) +} + +mod plugin_container { + use super::*; + + #[tokio::test] + async fn calls_a_function_and_returns_output() { + let (_sandbox, container) = create_sandboxed_container(); + + let output: Value = container + .call_func_with("register_tool", json!({ "id": "moonstone" })) + .await + .unwrap(); + + assert_eq!(output["name"], "moonstone"); + } + + #[tokio::test] + async fn detects_existence_of_functions() { + let (_sandbox, container) = create_sandboxed_container(); + + assert!(container.has_func("register_tool").await); + assert!(container.has_func("load_versions").await); + assert!(!container.has_func("does_not_exist").await); + } + + #[tokio::test] + async fn calls_same_function_repeatedly_across_instances() { + let (_sandbox, container) = create_sandboxed_container(); + + // This function initializes guest state (a tracing subscriber), + // so repeated calls verify that instances can be safely reused + for _ in 0..3 { + let output: Value = container + .call_func_with("register_tool", json!({ "id": "moonstone" })) + .await + .unwrap(); + + assert_eq!(output["name"], "moonstone"); + } + } + + #[tokio::test(flavor = "multi_thread")] + async fn calls_functions_concurrently() { + let (_sandbox, container) = create_sandboxed_container(); + let container = Arc::new(container); + let mut set = JoinSet::new(); + + for _ in 0..12 { + let container = Arc::clone(&container); + + set.spawn(async move { + let output: Value = container + .call_func_with( + "detect_version_files", + json!({ "context": create_context() }), + ) + .await + .unwrap(); + + assert_eq!(output["files"][0], ".moonstonerc"); + }); + } + + set.join_all().await; + } + + #[tokio::test(flavor = "multi_thread")] + async fn only_calls_cached_function_once_when_called_concurrently() { + let (_sandbox, container) = create_sandboxed_container(); + let container = Arc::new(container); + let calls = Arc::new(AtomicUsize::new(0)); + + container.set_on_call(Arc::new({ + let calls = Arc::clone(&calls); + + move |_func, input, _output| { + if input.is_some() { + calls.fetch_add(1, Ordering::SeqCst); + } + } + })); + + let mut set = JoinSet::new(); + + for _ in 0..12 { + let container = Arc::clone(&container); + + set.spawn(async move { + let output: Value = container + .cache_func_with( + "detect_version_files", + json!({ "context": create_context() }), + ) + .await + .unwrap(); + + assert_eq!(output["files"][0], ".moonstonerc"); + }); + } + + set.join_all().await; + + assert_eq!(calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn caches_function_output_per_input() { + let (_sandbox, container) = create_sandboxed_container(); + let calls = Arc::new(AtomicUsize::new(0)); + + container.set_on_call(Arc::new({ + let calls = Arc::clone(&calls); + + move |_func, input, _output| { + if input.is_some() { + calls.fetch_add(1, Ordering::SeqCst); + } + } + })); + + let input_one = json!({ "context": create_context() }); + let input_two = json!({ + "context": { + "temp_dir": "/temp", + "tool_dir": "/tool", + "working_dir": "/elsewhere", + } + }); + + for _ in 0..3 { + let _: Value = container + .cache_func_with("detect_version_files", input_one.clone()) + .await + .unwrap(); + } + + assert_eq!(calls.load(Ordering::SeqCst), 1); + + let _: Value = container + .cache_func_with("detect_version_files", input_two) + .await + .unwrap(); + + assert_eq!(calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn persists_var_state_across_calls_by_default() { + let sandbox = create_empty_sandbox(); + + // No opt-in, so a single instance is used for all calls + let container = create_container_for(sandbox.path(), "proto_api_usage", None); + + container + .call("set_var_state", "pooled-state") + .await + .unwrap(); + + let value = container.call("get_var_state", "").await.unwrap(); + + assert_eq!(String::from_utf8_lossy(&value), "pooled-state"); + + // A clean error (not a trap) must not discard the + // instance, otherwise its variable state is lost + container.call("does_not_exist", "").await.unwrap_err(); + + let value = container.call("get_var_state", "").await.unwrap(); + + assert_eq!(String::from_utf8_lossy(&value), "pooled-state"); + } + + #[tokio::test] + async fn discards_instance_and_state_after_a_trap() { + let sandbox = create_empty_sandbox(); + let container = create_container_for(sandbox.path(), "proto_api_usage", None); + + container + .call("set_var_state", "pooled-state") + .await + .unwrap(); + container.call("trigger_trap", "").await.unwrap_err(); + + // The instance was discarded, so a new instance + // is created without the previous variable state + let value = container.call("get_var_state", "").await.unwrap(); + + assert_eq!(String::from_utf8_lossy(&value), ""); + } + + #[tokio::test] + async fn recovers_after_failed_calls() { + let (_sandbox, container) = create_sandboxed_container(); + + // Clean errors are part of normal plugin operation, + // so trigger a few and ensure new calls still succeed + for _ in 0..3 { + let result = container + .call_func_with::<_, _, Value>( + "parse_version_file", + json!({ + "content": "invalid version!", + "context": create_context(), + "file": ".moonstonerc", + "path": "/cwd/.moonstonerc", + }), + ) + .await; + + assert!(result.is_err()); + } + + let output: Value = container + .call_func_with( + "detect_version_files", + json!({ "context": create_context() }), + ) + .await + .unwrap(); + + assert_eq!(output["files"][0], ".moonstonerc"); + } +} diff --git a/plugins/Cargo.lock b/plugins/Cargo.lock index 730f6b86f..97fe42b8b 100644 --- a/plugins/Cargo.lock +++ b/plugins/Cargo.lock @@ -2991,7 +2991,7 @@ dependencies = [ [[package]] name = "proto_core" -version = "0.58.0" +version = "0.58.2" dependencies = [ "convert_case 0.11.0", "docker_credential", @@ -4092,9 +4092,9 @@ dependencies = [ [[package]] name = "starbase_console" -version = "0.6.29" +version = "0.6.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b11aad63c3ab7b6dfcbbce2357cbb583bd3bf5d0a3e4329f84166f47cb7b6017" +checksum = "7e43e0375e24c13e6af8024e76a759aba61a695d0ad3445b4dc0446ded41888e" dependencies = [ "crossterm", "iocraft", @@ -4138,9 +4138,9 @@ dependencies = [ [[package]] name = "starbase_shell" -version = "0.12.5" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd0cac48b0878b82ed08b93578640cc010be0bb73bb400520feb4434fde8539" +checksum = "906e4773d01742ce1b70ccfdc03bdccdfa5202f7cb0d18e49efe71d517faca16" dependencies = [ "base64 0.22.1", "miette 7.6.0", diff --git a/plugins/api-usage/src/lib.rs b/plugins/api-usage/src/lib.rs index ac92be8e0..a8808c386 100644 --- a/plugins/api-usage/src/lib.rs +++ b/plugins/api-usage/src/lib.rs @@ -112,3 +112,20 @@ pub fn register_tool(_: ()) -> FnResult> { ..RegisterToolOutput::default() })) } + +#[plugin_fn] +pub fn set_var_state(input: String) -> FnResult<()> { + var::set("state", input)?; + + Ok(()) +} + +#[plugin_fn] +pub fn get_var_state(_: ()) -> FnResult { + Ok(var::get::("state")?.unwrap_or_default()) +} + +#[plugin_fn] +pub fn trigger_trap(_: ()) -> FnResult<()> { + panic!("This will trap!"); +}