use std::path::{Path, PathBuf};
use std::sync::Arc;
use async_trait::async_trait;
use cpex_core::{
context::PluginContext,
error::PluginError,
extensions::Extensions,
hooks::payload::PluginPayload,
plugin::{Plugin, PluginConfig},
registry::AnyHookHandler,
};
use serde::Deserialize;
use crate::error::HostError;
use crate::venv::VenvManager;
use crate::worker::WorkerClient;
use crate::{conversion, credentials};
const DEFAULT_MAX_CONTENT_SIZE: usize = 10_000_000;
const DEFAULT_TIMEOUT_SECS: u64 = 30;
const DEFAULT_SCRIPT_PATH: &str = "cpex/framework/isolated/worker.py";
const MAX_RESPAWN_ATTEMPTS: u32 = 3;
const RESPAWN_BACKOFF_BASE_MS: u64 = 100;
const RESPAWN_BUDGET_RESET_SECS: u64 = 60;
pub const DEFAULT_PLUGIN_DIR: &str = "plugins";
fn project_root() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
fn default_plugin_dirs() -> Vec<String> {
vec![project_root()
.join(DEFAULT_PLUGIN_DIR)
.display()
.to_string()]
}
pub const GLOBAL_REQUEST_ID_KEY: &str = "request_id";
const WORKER_KNOWN_CAPABILITIES: &[&str] = &[
"read_subject",
"read_roles",
"read_teams",
"read_claims",
"read_permissions",
"read_agent",
"read_headers",
"write_headers",
"read_labels",
"append_labels",
"read_delegation",
"append_delegation",
];
#[derive(Debug, Clone, Deserialize)]
pub struct VenvConfig {
pub class_name: String,
#[serde(default)]
pub requirements_file: Option<String>,
#[serde(default = "default_script_path")]
pub script_path: String,
#[serde(default = "default_max_content_size")]
pub max_content_size: usize,
#[serde(default = "default_timeout_secs")]
pub timeout_secs: u64,
}
fn default_script_path() -> String {
DEFAULT_SCRIPT_PATH.to_string()
}
fn default_max_content_size() -> usize {
DEFAULT_MAX_CONTENT_SIZE
}
fn default_timeout_secs() -> u64 {
DEFAULT_TIMEOUT_SECS
}
impl VenvConfig {
pub fn from_plugin_config(config: &PluginConfig) -> Result<Self, Box<PluginError>> {
let raw = config
.config
.clone()
.unwrap_or_else(|| serde_json::json!({}));
serde_json::from_value(raw).map_err(|e| {
PluginError::Config {
message: format!(
"plugin '{}' (isolated_venv): invalid `config:` block — {}. \
`class_name` is required (the fully-qualified Python class, \
e.g. my_pkg.filters.PiiFilter)",
config.name, e
),
}
.boxed()
})
}
}
pub struct IsolatedPythonPlugin {
config: PluginConfig,
venv_config: VenvConfig,
plugin_dirs: Vec<String>,
venv: Option<VenvManager>,
worker: tokio::sync::RwLock<Option<Arc<WorkerClient>>>,
worker_override: Option<PathBuf>,
worker_cwd: Option<PathBuf>,
respawn: tokio::sync::Mutex<RespawnState>,
}
#[derive(Debug)]
struct RespawnState {
attempts: u32,
window_started: Option<std::time::Instant>,
}
impl RespawnState {
fn new() -> Self {
Self {
attempts: 0,
window_started: None,
}
}
fn try_reserve(&mut self) -> Option<u32> {
let now = std::time::Instant::now();
let expired = self
.window_started
.is_some_and(|s| now.duration_since(s).as_secs() >= RESPAWN_BUDGET_RESET_SECS);
if expired {
self.attempts = 0;
self.window_started = None;
}
if self.attempts >= MAX_RESPAWN_ATTEMPTS {
return None;
}
self.attempts += 1;
self.window_started.get_or_insert(now);
Some(self.attempts)
}
fn record_success(&mut self) {
self.attempts = 0;
self.window_started = None;
}
}
impl IsolatedPythonPlugin {
pub fn from_config(config: &PluginConfig) -> Result<Self, Box<PluginError>> {
let venv_config = VenvConfig::from_plugin_config(config)?;
let plugin_dirs = default_plugin_dirs();
let venv = VenvManager::new(
&plugin_dirs,
&venv_config.class_name,
venv_config.requirements_file.as_deref(),
config.version.as_deref(),
)
.ok();
Ok(Self {
config: config.clone(),
venv_config,
plugin_dirs,
venv,
worker: tokio::sync::RwLock::new(None),
worker_override: None,
worker_cwd: None,
respawn: tokio::sync::Mutex::new(RespawnState::new()),
})
}
pub fn plugin_dirs(&self) -> &[String] {
&self.plugin_dirs
}
#[cfg(any(test, feature = "testing"))]
pub fn with_worker_cwd(mut self, cwd: PathBuf) -> Self {
self.worker_cwd = Some(cwd);
self
}
#[cfg(any(test, feature = "testing"))]
pub fn with_plugin_dirs(mut self, dirs: Vec<String>) -> Self {
self.venv = VenvManager::new(
&dirs,
&self.venv_config.class_name,
self.venv_config.requirements_file.as_deref(),
self.config.version.as_deref(),
)
.ok();
self.plugin_dirs = dirs;
self
}
#[cfg(any(test, feature = "testing"))]
pub fn with_worker_override(mut self, script: PathBuf) -> Self {
self.worker_override = Some(script);
self
}
pub fn venv_config(&self) -> &VenvConfig {
&self.venv_config
}
#[cfg(any(test, feature = "testing"))]
pub async fn worker_stderr(&self) -> Vec<String> {
match self.worker.read().await.as_ref() {
Some(worker) => worker.stderr_lines().await,
None => Vec::new(),
}
}
async fn launch_worker(&self) -> Result<WorkerClient, HostError> {
let (python, script) = match self.worker_override.as_ref() {
Some(script) => (which_python3()?, script.clone()),
None => {
let venv = self.venv.as_ref().ok_or_else(|| HostError::Config {
message: format!(
"could not resolve a venv layout under {} — check that `class_name` \
('{}') begins with a package segment",
self.plugin_dirs.join(", "),
self.venv_config.class_name,
),
})?;
venv.ensure().await?;
let script = crate::worker::resolve_worker_script(
&venv.layout().venv_path,
&self.venv_config.script_path,
)?;
(venv.python_executable(), script)
},
};
WorkerClient::spawn(
&python,
&script,
self.worker_cwd.as_deref(),
self.venv_config.max_content_size,
self.venv_config.timeout_secs,
)
.await
}
async fn worker(&self) -> Result<Arc<WorkerClient>, Box<PluginError>> {
let to_plugin_error = |e: HostError| e.into_plugin_error(&self.config.name);
if let Some(worker) = self.worker.read().await.clone() {
if worker.is_alive() {
return Ok(worker);
}
} else {
return Err(to_plugin_error(HostError::WorkerStart {
message: "plugin has no worker — initialize() did not run or it failed".into(),
}));
}
self.respawn_worker().await
}
async fn respawn_worker(&self) -> Result<Arc<WorkerClient>, Box<PluginError>> {
let to_plugin_error = |e: HostError| e.into_plugin_error(&self.config.name);
let mut respawn = self.respawn.lock().await;
if let Some(worker) = self.worker.read().await.clone() {
if worker.is_alive() {
return Ok(worker);
}
}
let Some(attempt) = respawn.try_reserve() else {
tracing::error!(
plugin = %self.config.name,
max_attempts = MAX_RESPAWN_ATTEMPTS,
"worker died and the respawn budget is exhausted; giving up"
);
return Err(to_plugin_error(HostError::WorkerStart {
message: format!(
"worker died and could not be respawned after {MAX_RESPAWN_ATTEMPTS} attempts \
— the plugin's worker is failing to start (check its venv and dependencies)"
),
}));
};
let backoff = std::time::Duration::from_millis(
RESPAWN_BACKOFF_BASE_MS.saturating_mul(1 << (attempt - 1)),
);
tracing::warn!(
plugin = %self.config.name,
attempt,
max_attempts = MAX_RESPAWN_ATTEMPTS,
backoff_ms = backoff.as_millis(),
"worker is dead; respawning"
);
tokio::time::sleep(backoff).await;
if let Some(dead) = self.worker.write().await.take() {
if let Err(e) = dead.shutdown().await {
tracing::debug!(plugin = %self.config.name, "reaping the dead worker: {e}");
}
}
let worker = Arc::new(self.launch_worker().await.map_err(to_plugin_error)?);
self.verify_worker_understands(&worker)
.await
.map_err(to_plugin_error)?;
*self.worker.write().await = Some(Arc::clone(&worker));
tracing::info!(
plugin = %self.config.name,
attempt,
"worker respawned; awaiting proof it can serve a request"
);
Ok(worker)
}
async fn note_worker_healthy(&self) {
let mut respawn = self.respawn.lock().await;
if respawn.attempts > 0 {
respawn.record_success();
}
}
fn worker_capabilities(&self) -> Vec<String> {
let (known, unknown): (Vec<String>, Vec<String>) = self
.config
.capabilities
.iter()
.cloned()
.partition(|c| WORKER_KNOWN_CAPABILITIES.contains(&c.as_str()));
if !unknown.is_empty() {
tracing::warn!(
plugin = %self.config.name,
dropped = ?unknown,
"the installed cpex framework does not model these capabilities; \
omitting them from the worker's config so it can still validate"
);
}
known
}
async fn verify_worker_understands(&self, worker: &WorkerClient) -> Result<(), HostError> {
let required = self.required_worker_features();
if required.is_empty() {
return Ok(());
}
let reported = worker.capabilities().await?;
let missing: Vec<&str> = required
.iter()
.copied()
.filter(|f| !reported.has(f))
.collect();
if missing.is_empty() {
tracing::debug!(
plugin = %self.config.name,
protocol_version = reported.protocol_version,
cpex_version = ?reported.cpex_version,
"worker handshake satisfied"
);
return Ok(());
}
let mut declared: Vec<&str> = self
.config
.capabilities
.iter()
.map(String::as_str)
.collect();
declared.sort_unstable();
Err(HostError::Credential {
message: format!(
"the installed cpex worker does not implement {} — which plugin '{}' needs given \
its declared capabilities ({}) and hooks ({}). The worker reported protocol \
version {} and features [{}] (cpex {}). Running anyway would drop those fields \
silently, so this plugin is failed closed at startup; upgrade the cpex package \
in the plugin's venv.",
missing.join(", "),
self.config.name,
declared.join(", "),
self.config.hooks.join(", "),
reported.protocol_version,
reported
.features
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
.join(", "),
reported
.cpex_version
.as_deref()
.unwrap_or("version unknown"),
),
})
}
fn required_worker_features(&self) -> Vec<&'static str> {
let mut required = Vec::new();
let declares_credential_cap = self.config.capabilities.iter().any(|c| {
c == crate::credentials::CAP_READ_INBOUND || c == crate::credentials::CAP_READ_DELEGATED
});
if declares_credential_cap {
required.push(crate::worker::FEATURE_CREDENTIAL);
}
let declares_extension_cap = self
.config
.capabilities
.iter()
.any(|c| WORKER_KNOWN_CAPABILITIES.contains(&c.as_str()));
if declares_extension_cap {
required.push(crate::worker::FEATURE_EXTENSIONS);
}
required
}
fn build_task(
&self,
hook_name: &str,
payload: serde_json::Value,
ctx: &PluginContext,
) -> Result<serde_json::Value, HostError> {
let config_json = serde_json::json!({
"name": self.config.name,
"kind": self.config.kind,
"hooks": self.config.hooks,
"capabilities": self.worker_capabilities(),
"config": self.config.config.clone().unwrap_or(serde_json::Value::Null),
});
let config_string =
serde_json::to_string(&config_json).map_err(|e| HostError::Protocol {
message: format!("could not serialize the plugin config for the worker: {e}"),
})?;
let request_id = ctx
.get_global(GLOBAL_REQUEST_ID_KEY)
.and_then(serde_json::Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
Ok(serde_json::json!({
"task_type": crate::worker::TASK_LOAD_AND_RUN_HOOK,
"plugin_dirs": self.plugin_dirs,
"class_name": self.venv_config.class_name,
"config": config_string,
"hook_type": hook_name,
"plugin_name": self.config.name,
"payload": payload,
"context": {
"state": ctx.local_state,
"global_context": {
"request_id": request_id,
"state": ctx.global_state,
},
"metadata": {},
},
}))
}
}
#[async_trait]
impl Plugin for IsolatedPythonPlugin {
fn config(&self) -> &PluginConfig {
&self.config
}
async fn initialize(&self) -> Result<(), Box<PluginError>> {
let worker = self
.launch_worker()
.await
.map_err(|e| e.into_plugin_error(&self.config.name))?;
self.verify_worker_understands(&worker)
.await
.map_err(|e| e.into_plugin_error(&self.config.name))?;
*self.worker.write().await = Some(Arc::new(worker));
Ok(())
}
async fn shutdown(&self) -> Result<(), Box<PluginError>> {
let worker = self.worker.write().await.take();
let result = match worker {
Some(worker) => worker
.shutdown()
.await
.map_err(|e| e.into_plugin_error(&self.config.name)),
None => Ok(()),
};
if let Some(venv) = self.venv.as_ref() {
if let Err(e) = venv.release_owner().await {
tracing::warn!(
plugin = %self.config.name,
"could not release the venv owner claim: {e}"
);
}
}
result
}
}
fn which_python3() -> Result<PathBuf, HostError> {
let candidates = [
"/usr/bin/python3",
"/usr/local/bin/python3",
"/opt/homebrew/bin/python3",
];
if let Some(found) = candidates.iter().map(Path::new).find(|p| p.exists()) {
return Ok(found.to_path_buf());
}
let output = std::process::Command::new("sh")
.args(["-c", "command -v python3"])
.output()
.map_err(|e| HostError::WorkerStart {
message: format!("could not look up python3: {e}"),
})?;
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() {
return Err(HostError::WorkerStart {
message: "python3 was not found on PATH".into(),
});
}
Ok(PathBuf::from(path))
}
pub struct PythonHookAdapter {
plugin: Arc<IsolatedPythonPlugin>,
hook_name: &'static str,
}
impl PythonHookAdapter {
pub fn new(plugin: Arc<IsolatedPythonPlugin>, hook_name: &'static str) -> Self {
Self { plugin, hook_name }
}
}
#[async_trait]
impl AnyHookHandler for PythonHookAdapter {
async fn invoke(
&self,
payload: &dyn PluginPayload,
extensions: &Extensions,
ctx: &mut PluginContext,
) -> Result<Box<dyn std::any::Any + Send + Sync>, Box<PluginError>> {
let plugin_name = &self.plugin.config.name;
let to_plugin_error = |e: HostError| e.into_plugin_error(plugin_name);
let payload_json = conversion::serialize_payload(payload).map_err(to_plugin_error)?;
let mut task = self
.plugin
.build_task(self.hook_name, payload_json, ctx)
.map_err(to_plugin_error)?;
credentials::attach_credential(
&mut task,
self.hook_name,
extensions,
&self.plugin.config.capabilities,
)
.map_err(to_plugin_error)?;
crate::extensions::attach_extensions(&mut task, extensions).map_err(to_plugin_error)?;
let worker = self.plugin.worker().await?;
let response = worker.send_task(task).await.map_err(to_plugin_error)?;
self.plugin.note_worker_healthy().await;
apply_context_deltas(&response, ctx);
let fields = conversion::response_to_result(self.hook_name, response, extensions)
.map_err(to_plugin_error)?;
Ok(Box::new(fields))
}
fn hook_type_name(&self) -> &'static str {
self.hook_name
}
}
fn apply_context_deltas(response: &serde_json::Value, ctx: &mut PluginContext) {
let Some(context) = response.get("context") else {
return;
};
if let Some(state) = context.get("state").and_then(|v| v.as_object()) {
for (key, value) in state {
ctx.local_state.insert(key.clone(), value.clone());
}
}
if let Some(global) = context
.get("global_context")
.and_then(|g| g.get("state"))
.and_then(|v| v.as_object())
{
for (key, value) in global {
ctx.global_state.insert(key.clone(), value.clone());
}
}
}
#[cfg(test)]
mod tests {
use crate::testing::TempDir;
use super::*;
fn config_with(value: serde_json::Value) -> PluginConfig {
PluginConfig {
name: "py-plugin".into(),
kind: crate::factory::KIND.into(),
hooks: vec!["tool_pre_invoke".into()],
config: Some(value),
..Default::default()
}
}
#[test]
fn full_config_block_deserializes_every_field() {
let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
"class_name": "my_pkg.filters.PiiFilter",
"requirements_file": "requirements.txt",
"plugin_dirs": ["./plugins", "./vendor"],
"script_path": "custom/worker.py",
"max_content_size": 2048,
"timeout_secs": 5,
})))
.expect("a fully populated block parses");
assert_eq!(cfg.class_name, "my_pkg.filters.PiiFilter");
assert_eq!(cfg.requirements_file.as_deref(), Some("requirements.txt"));
assert_eq!(cfg.script_path, "custom/worker.py");
assert_eq!(cfg.max_content_size, 2048);
assert_eq!(cfg.timeout_secs, 5);
}
#[test]
fn absent_optionals_fall_back_to_documented_defaults() {
let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
"class_name": "my_pkg.Minimal",
})))
.expect("class_name alone is a valid block");
assert_eq!(
cfg.max_content_size, 10_000_000,
"matches client.py's max_content_size"
);
assert_eq!(
cfg.timeout_secs, 30,
"matches venv_comm.py's send_task timeout"
);
assert_eq!(cfg.script_path, "cpex/framework/isolated/worker.py");
assert!(
cfg.requirements_file.is_none(),
"no requirements file is valid"
);
}
#[test]
fn missing_class_name_errors() {
let err = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
"plugin_dirs": ["./plugins"],
})))
.expect_err("class_name is required");
assert!(matches!(*err, PluginError::Config { .. }));
assert!(err.to_string().contains("class_name"));
}
#[test]
fn absent_config_block_errors_rather_than_panicking() {
let cfg = PluginConfig {
name: "no-config".into(),
kind: crate::factory::KIND.into(),
hooks: vec!["tool_pre_invoke".into()],
config: None,
..Default::default()
};
let err = VenvConfig::from_plugin_config(&cfg)
.expect_err("an absent block cannot supply class_name");
assert!(matches!(*err, PluginError::Config { .. }));
}
fn expected_default_dir() -> String {
std::env::current_dir()
.unwrap()
.join("plugins")
.display()
.to_string()
}
#[test]
fn plugin_dirs_default_to_plugins_at_the_project_root() {
let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
"class_name": "my_pkg.Minimal",
})))
.expect("class_name alone is a valid block");
assert_eq!(
plugin.plugin_dirs(),
[expected_default_dir()],
"the host supplies the plugin dir; the config no longer can"
);
assert!(
Path::new(&plugin.plugin_dirs()[0]).is_absolute(),
"the resolved dir must be absolute: {:?}",
plugin.plugin_dirs()
);
}
#[test]
fn a_plugin_dirs_key_in_the_config_block_is_ignored() {
let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
"class_name": "my_pkg.Minimal",
"plugin_dirs": ["/somewhere/else", "/and/here"],
})))
.expect("an ignored key must not fail the parse");
assert_eq!(
plugin.plugin_dirs(),
[expected_default_dir()],
"`plugin_dirs` in the config block must be ignored"
);
}
#[test]
fn the_top_level_plugin_dirs_key_is_also_ignored() {
let yaml = r#"
plugin_dirs: ["./top-level-dirs"]
plugins:
- name: py-plugin
kind: isolated_venv
hooks: [tool_pre_invoke]
config:
class_name: my_pkg.Minimal
"#;
let cpex_config: cpex_core::config::CpexConfig =
serde_yaml::from_str(yaml).expect("valid YAML");
assert_eq!(
cpex_config.plugin_dirs,
vec!["./top-level-dirs"],
"the top-level key still parses — it is simply not the host's source"
);
let plugin =
IsolatedPythonPlugin::from_config(&cpex_config.plugins[0]).expect("config parses");
assert_eq!(
plugin.plugin_dirs(),
[expected_default_dir()],
"the top-level key must not steer the host's plugin dir"
);
}
#[test]
fn the_resolved_plugin_dir_is_what_the_worker_receives() {
let plugin = IsolatedPythonPlugin::from_config(&config_with(serde_json::json!({
"class_name": "my_pkg.Minimal",
"plugin_dirs": ["/ignored"],
})))
.expect("parses");
let task = plugin
.build_task(
"tool_pre_invoke",
serde_json::json!({"name": "t"}),
&PluginContext::new(),
)
.expect("task builds");
assert_eq!(
task["plugin_dirs"],
serde_json::json!([expected_default_dir()]),
"the worker must be told the resolved dir, not the ignored one"
);
}
fn worker_config(task: &serde_json::Value) -> serde_json::Value {
serde_json::from_str(task["config"].as_str().expect("config is a string"))
.expect("the worker's config parses")
}
fn task_for_capabilities(capabilities: &[&str]) -> serde_json::Value {
let mut config = config_with(serde_json::json!({"class_name": "my_pkg.Minimal"}));
config.capabilities = capabilities.iter().map(|c| (*c).to_string()).collect();
IsolatedPythonPlugin::from_config(&config)
.expect("parses")
.build_task(
"tool_pre_invoke",
serde_json::json!({"name": "t"}),
&PluginContext::new(),
)
.expect("task builds")
}
#[test]
fn declared_capabilities_reach_the_workers_config() {
let config = worker_config(&task_for_capabilities(&["read_labels", "append_labels"]));
let mut sent: Vec<&str> = config["capabilities"]
.as_array()
.expect("capabilities is an array")
.iter()
.map(|c| c.as_str().expect("a string"))
.collect();
sent.sort_unstable();
assert_eq!(
sent,
["append_labels", "read_labels"],
"the worker must be told what the plugin declared"
);
}
#[test]
fn a_capability_the_python_model_rejects_is_not_forwarded() {
let config = worker_config(&task_for_capabilities(&["read_labels", "read_client"]));
assert_eq!(
config["capabilities"],
serde_json::json!(["read_labels"]),
"a name the installed framework cannot validate must be dropped, \
not sent and not escalated into a task failure"
);
}
#[test]
fn a_plugin_declaring_nothing_sends_an_empty_capability_list() {
let config = worker_config(&task_for_capabilities(&[]));
assert_eq!(
config["capabilities"],
serde_json::json!([]),
"no capabilities is an empty list, not a missing field"
);
}
const ADAPTER_STUB: &str = r#"
import json, os, sys
here = os.path.dirname(os.path.abspath(__file__))
def mode_file(name, default=""):
try:
with open(os.path.join(here, name)) as f:
return f.read().strip()
except FileNotFoundError:
return default
def mode():
return mode_file("mode", "allow")
while True:
line = sys.stdin.readline()
if not line:
break
line = line.strip()
if not line:
continue
task = json.loads(line)
rid = task.get("request_id", "unknown")
if task.get("task_type") == "shutdown":
print(json.dumps({"status": "success", "request_id": rid}), flush=True)
break
# Answered before the recording below, so the spawn-time handshake does not
# overwrite the `last_task.json` the tests assert their hook task against.
# This stub stands in for a *current* worker, so it claims the features the
# real one implements; `handshake_mode` covers the old-worker case.
if task.get("task_type") == "capabilities":
hs = mode_file("handshake_mode")
if hs == "unsupported":
# Exactly what a worker predating the handshake replies.
print(json.dumps({"status": "error", "message": "task type not supported.", "request_id": rid}), flush=True)
continue
features = [] if hs == "no_features" else ["credential", "extensions", "modified_extensions"]
print(json.dumps({
"status": "success", "protocol_version": 1,
"features": features, "cpex_version": "0.1.2", "request_id": rid,
}), flush=True)
continue
# Record the task so the test can assert on what the host actually sent.
with open(os.path.join(here, "last_task.json"), "w") as f:
json.dump(task, f)
# ...and which process served it, so a respawn test can prove the request
# was answered by a *new* worker rather than by the original recovering.
with open(os.path.join(here, "last_pid"), "w") as f:
f.write(str(os.getpid()))
m = mode()
# `die` exits without replying, which is how a test kills the worker so the
# *next* invocation has a dead one to respawn.
if m == "die":
sys.exit(9)
if m == "deny":
response = {
"continue_processing": False,
"violation": {"code": "PII_DETECTED", "reason": "email in args", "details": {"field": "q"}},
}
elif m == "error":
response = {"status": "error", "message": "the plugin raised ValueError"}
elif m == "context":
response = {
"continue_processing": True,
"context": {"state": {"seen": 1}, "global_context": {"state": {"shared": "yes"}}},
}
elif m == "modify":
response = {
"continue_processing": True,
"modified_payload": {"name": "search", "args": {"q": "[REDACTED]"}},
}
else:
response = {"continue_processing": True}
response["request_id"] = rid
print(json.dumps(response), flush=True)
"#;
async fn adapter_plugin(
hooks: &[&str],
capabilities: &[&str],
) -> (TempDir, Arc<IsolatedPythonPlugin>) {
let (dir, plugin) = uninitialized_adapter_plugin(hooks, capabilities);
plugin.initialize().await.expect("the stub worker launches");
(dir, plugin)
}
fn set_mode(dir: &TempDir, mode: &str) {
std::fs::write(dir.path().join("mode"), mode).unwrap();
}
fn set_handshake_mode(dir: &TempDir, mode: &str) {
std::fs::write(dir.path().join("handshake_mode"), mode).unwrap();
}
fn uninitialized_adapter_plugin(
hooks: &[&str],
capabilities: &[&str],
) -> (TempDir, Arc<IsolatedPythonPlugin>) {
let dir = TempDir::new();
let script = dir.path().join("adapter_stub.py");
std::fs::write(&script, ADAPTER_STUB).unwrap();
let config = PluginConfig {
name: "py-plugin".into(),
kind: crate::factory::KIND.into(),
hooks: hooks.iter().map(|h| (*h).to_string()).collect(),
capabilities: capabilities.iter().map(|c| (*c).to_string()).collect(),
config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
..Default::default()
};
let plugin = Arc::new(
IsolatedPythonPlugin::from_config(&config)
.expect("config parses")
.with_worker_override(script),
);
(dir, plugin)
}
fn last_task(dir: &TempDir) -> serde_json::Value {
let raw = std::fs::read_to_string(dir.path().join("last_task.json"))
.expect("the stub recorded a task");
serde_json::from_str(&raw).expect("recorded task is JSON")
}
async fn invoke(
plugin: &Arc<IsolatedPythonPlugin>,
hook: &'static str,
payload: &dyn PluginPayload,
ctx: &mut PluginContext,
) -> Result<cpex_core::executor::ErasedResultFields, Box<PluginError>> {
let adapter = PythonHookAdapter::new(Arc::clone(plugin), hook);
let erased = adapter.invoke(payload, &Extensions::default(), ctx).await?;
Ok(cpex_core::executor::extract_erased(erased)
.expect("the adapter returns ErasedResultFields"))
}
#[tokio::test]
async fn an_invoke_round_trips_and_returns_allow() {
if crate::testing::skip_without_python3("an_invoke_round_trips_and_returns_allow") {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "allow");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
args: Some(std::collections::HashMap::from([(
"q".into(),
serde_json::json!("rust"),
)])),
headers: None,
};
let mut ctx = PluginContext::new();
let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.expect("the round trip succeeds");
assert!(fields.continue_processing);
assert!(fields.violation.is_none());
let task = last_task(&dir);
assert_eq!(task["task_type"], "load_and_run_hook");
assert_eq!(task["hook_type"], "tool_pre_invoke");
assert_eq!(task["plugin_name"], "py-plugin");
assert_eq!(task["class_name"], "my_pkg.Plugin");
assert_eq!(task["payload"]["name"], "search");
assert_eq!(task["payload"]["args"]["q"], "rust");
let config_str = task["config"]
.as_str()
.expect("config must be a JSON string");
let config: serde_json::Value = serde_json::from_str(config_str).unwrap();
assert_eq!(config["name"], "py-plugin");
plugin.shutdown().await.expect("shuts down");
}
#[tokio::test]
async fn a_deny_response_becomes_a_deny_result_with_its_violation() {
if crate::testing::skip_without_python3(
"a_deny_response_becomes_a_deny_result_with_its_violation",
) {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "deny");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.expect("a deny is a successful invocation that returns a deny");
assert!(!fields.continue_processing);
let violation = fields.violation.expect("a deny must carry its violation");
assert_eq!(violation.code, "PII_DETECTED");
assert_eq!(violation.reason, "email in args");
assert_eq!(violation.details["field"], "q");
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_worker_error_surfaces_as_a_plugin_error_for_the_executor_to_police() {
if crate::testing::skip_without_python3(
"a_worker_error_surfaces_as_a_plugin_error_for_the_executor_to_police",
) {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "error");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
panic!("a worker error response must not read as success");
};
match *err {
PluginError::Execution {
ref plugin_name,
ref code,
ref message,
..
} => {
assert_eq!(plugin_name, "py-plugin");
assert_eq!(code.as_deref(), Some("worker_error"));
assert!(
message.contains("ValueError"),
"the plugin's message should survive: {message}"
);
},
ref other => panic!("expected an Execution error, got {other:?}"),
}
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn context_deltas_are_written_back_into_the_plugin_context() {
if crate::testing::skip_without_python3(
"context_deltas_are_written_back_into_the_plugin_context",
) {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "context");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
ctx.set_local("preexisting", serde_json::json!("kept"));
invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.unwrap();
assert_eq!(ctx.get_local("seen"), Some(&serde_json::json!(1)));
assert_eq!(ctx.get_global("shared"), Some(&serde_json::json!("yes")));
assert_eq!(
ctx.get_local("preexisting"),
Some(&serde_json::json!("kept")),
"deltas merge — a plugin touching one key must not blank the rest"
);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_modified_payload_comes_back_as_the_hooks_typed_payload() {
if crate::testing::skip_without_python3(
"a_modified_payload_comes_back_as_the_hooks_typed_payload",
) {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "modify");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.unwrap();
let modified = fields.modified_payload.expect("the modification survives");
let typed = modified
.as_any()
.downcast_ref::<crate::legacy::ToolPreInvokePayload>()
.expect("must come back as the tool payload or the executor's downcast fails");
assert_eq!(typed.args.as_ref().unwrap()["q"], "[REDACTED]");
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_cmf_hook_sends_the_message_payload_shape() {
if crate::testing::skip_without_python3("a_cmf_hook_sends_the_message_payload_shape") {
return;
}
let (dir, plugin) = adapter_plugin(&["cmf.tool_pre_invoke"], &[]).await;
set_mode(&dir, "allow");
let payload = cpex_core::cmf::MessagePayload {
message: cpex_core::cmf::Message::text(cpex_core::cmf::Role::User, "hello"),
};
let mut ctx = PluginContext::new();
invoke(&plugin, "cmf.tool_pre_invoke", &payload, &mut ctx)
.await
.unwrap();
let task = last_task(&dir);
assert_eq!(task["hook_type"], "cmf.tool_pre_invoke");
assert_eq!(
task["payload"]["message"]["role"], "user",
"a cmf hook must send the CMF message payload, not the generic wrapper"
);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn invoking_before_initialize_is_a_plugin_error_not_a_panic() {
let config = PluginConfig {
name: "uninitialized".into(),
kind: crate::factory::KIND.into(),
hooks: vec!["tool_pre_invoke".into()],
config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
..Default::default()
};
let plugin = Arc::new(IsolatedPythonPlugin::from_config(&config).unwrap());
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
panic!("dispatch without a worker cannot succeed");
};
assert!(matches!(*err, PluginError::Execution { .. }));
}
#[tokio::test]
async fn a_credential_capability_attaches_the_dto_on_an_identity_hook() {
if crate::testing::skip_without_python3(
"a_credential_capability_attaches_the_dto_on_an_identity_hook",
) {
return;
}
use cpex_core::extensions::raw_credentials::{
RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
};
let (dir, plugin) =
adapter_plugin(&["identity_resolve"], &["read_inbound_credentials"]).await;
set_mode(&dir, "allow");
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new("E2E-SECRET-TOKEN", "Authorization", TokenKind::Jwt),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let payload = crate::legacy::IdentityResolvePayload::default();
let mut ctx = PluginContext::new();
let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve");
adapter
.invoke(&payload, &extensions, &mut ctx)
.await
.expect("dispatch succeeds");
let task = last_task(&dir);
assert_eq!(task["credential"]["inbound"]["token"], "E2E-SECRET-TOKEN");
assert_eq!(task["credential"]["inbound"]["kind"], "jwt");
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn no_capability_means_no_credential_reaches_the_worker() {
if crate::testing::skip_without_python3(
"no_capability_means_no_credential_reaches_the_worker",
) {
return;
}
use cpex_core::extensions::raw_credentials::{
RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
};
let (dir, plugin) = adapter_plugin(&["identity_resolve"], &[]).await;
set_mode(&dir, "allow");
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new("E2E-SECRET-TOKEN", "Authorization", TokenKind::Jwt),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let payload = crate::legacy::IdentityResolvePayload::default();
let mut ctx = PluginContext::new();
let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve");
adapter
.invoke(&payload, &extensions, &mut ctx)
.await
.unwrap();
let task = last_task(&dir);
assert!(task.get("credential").is_none(), "no credential field");
assert!(
!serde_json::to_string(&task)
.unwrap()
.contains("E2E-SECRET-TOKEN"),
"no token bytes anywhere in the task the worker received"
);
plugin.shutdown().await.unwrap();
}
fn last_pid(dir: &TempDir) -> u32 {
std::fs::read_to_string(dir.path().join("last_pid"))
.expect("the stub recorded its pid")
.trim()
.parse()
.expect("pid is numeric")
}
async fn await_worker_death(plugin: &Arc<IsolatedPythonPlugin>) {
for _ in 0..200 {
let alive = plugin
.worker
.read()
.await
.as_ref()
.is_some_and(|w| w.is_alive());
if !alive {
return;
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
panic!("the worker never registered as dead");
}
#[tokio::test]
async fn a_dead_worker_is_respawned_on_the_next_invocation() {
if crate::testing::skip_without_python3("a_dead_worker_is_respawned_on_the_next_invocation")
{
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "allow");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.expect("the first invocation succeeds");
let original_pid = last_pid(&dir);
set_mode(&dir, "die");
let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
await_worker_death(&plugin).await;
set_mode(&dir, "allow");
let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.expect("a dead worker must be replaced, not left broken");
assert!(fields.continue_processing);
let new_pid = last_pid(&dir);
assert_ne!(
original_pid, new_pid,
"the request must be served by a respawned process"
);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_worker_that_cannot_honor_a_declared_credential_fails_closed_at_startup() {
if crate::testing::skip_without_python3(
"a_worker_that_cannot_honor_a_declared_credential_fails_closed_at_startup",
) {
return;
}
let (dir, plugin) =
uninitialized_adapter_plugin(&["identity_resolve"], &["read_inbound_credentials"]);
set_handshake_mode(&dir, "unsupported");
let err = plugin
.initialize()
.await
.expect_err("a worker that would drop the credential must not be installed");
let message = err.to_string();
assert!(
message.contains("does not implement credential"),
"the failure must name the missing feature: {message}"
);
assert!(
message.contains("upgrade the cpex package"),
"the failure must tell an operator what to do: {message}"
);
assert!(
plugin.worker.read().await.is_none(),
"a failed handshake must not leave a worker in the slot"
);
}
#[tokio::test]
async fn a_plugin_declaring_nothing_gated_still_runs_on_an_old_worker() {
if crate::testing::skip_without_python3(
"a_plugin_declaring_nothing_gated_still_runs_on_an_old_worker",
) {
return;
}
let (dir, plugin) = uninitialized_adapter_plugin(&["tool_pre_invoke"], &[]);
set_handshake_mode(&dir, "unsupported");
plugin
.initialize()
.await
.expect("a plugin needing no gated field must still start on an old worker");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
let fields = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.expect("the hook still dispatches");
assert!(fields.continue_processing);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_worker_answering_the_handshake_with_no_features_also_fails_closed() {
if crate::testing::skip_without_python3(
"a_worker_answering_the_handshake_with_no_features_also_fails_closed",
) {
return;
}
let (dir, plugin) =
uninitialized_adapter_plugin(&["tool_pre_invoke"], &["read_labels", "append_labels"]);
set_handshake_mode(&dir, "no_features");
let err = plugin
.initialize()
.await
.expect_err("an extensions-declaring plugin needs a worker that delivers the field");
let message = err.to_string();
assert!(
message.contains("does not implement extensions"),
"an extensions capability requires the extensions feature: {message}"
);
}
#[tokio::test]
async fn a_respawned_worker_receives_the_same_capability_handshake() {
if crate::testing::skip_without_python3(
"a_respawned_worker_receives_the_same_capability_handshake",
) {
return;
}
use cpex_core::extensions::raw_credentials::{
RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
};
let (dir, plugin) =
adapter_plugin(&["identity_resolve"], &["read_inbound_credentials"]).await;
set_mode(&dir, "allow");
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new("RESPAWN-SECRET", "Authorization", TokenKind::Jwt),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let payload = crate::legacy::IdentityResolvePayload::default();
let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve");
let mut ctx = PluginContext::new();
adapter
.invoke(&payload, &extensions, &mut ctx)
.await
.expect("the first dispatch succeeds");
let original_pid = last_pid(&dir);
assert_eq!(
last_task(&dir)["credential"]["inbound"]["token"],
"RESPAWN-SECRET"
);
set_mode(&dir, "die");
let mut ctx = PluginContext::new();
let _ = adapter.invoke(&payload, &extensions, &mut ctx).await;
await_worker_death(&plugin).await;
set_mode(&dir, "allow");
let mut ctx = PluginContext::new();
adapter
.invoke(&payload, &extensions, &mut ctx)
.await
.expect("dispatch succeeds against the respawned worker");
assert_ne!(original_pid, last_pid(&dir), "a new process served it");
let task = last_task(&dir);
assert_eq!(
task["credential"]["inbound"]["token"], "RESPAWN-SECRET",
"the respawned worker must get the same credential handshake"
);
assert_eq!(task["credential"]["inbound"]["kind"], "jwt");
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_respawned_worker_gets_no_credential_it_did_not_declare() {
if crate::testing::skip_without_python3(
"a_respawned_worker_gets_no_credential_it_did_not_declare",
) {
return;
}
use cpex_core::extensions::raw_credentials::{
RawCredentialsExtension, RawInboundToken, TokenKind, TokenRole,
};
let (dir, plugin) = adapter_plugin(&["identity_resolve"], &[]).await;
set_mode(&dir, "allow");
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new("MUST-NOT-LEAK", "Authorization", TokenKind::Jwt),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let payload = crate::legacy::IdentityResolvePayload::default();
let adapter = PythonHookAdapter::new(Arc::clone(&plugin), "identity_resolve");
let mut ctx = PluginContext::new();
adapter
.invoke(&payload, &extensions, &mut ctx)
.await
.unwrap();
set_mode(&dir, "die");
let mut ctx = PluginContext::new();
let _ = adapter.invoke(&payload, &extensions, &mut ctx).await;
await_worker_death(&plugin).await;
set_mode(&dir, "allow");
let mut ctx = PluginContext::new();
adapter
.invoke(&payload, &extensions, &mut ctx)
.await
.expect("dispatch succeeds after respawn");
let task = last_task(&dir);
assert!(
task.get("credential").is_none(),
"respawn must not widen the capability gate"
);
assert!(
!serde_json::to_string(&task)
.unwrap()
.contains("MUST-NOT-LEAK"),
"no token bytes may reach a worker that declared nothing"
);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn a_worker_that_will_not_stay_up_gives_up_with_a_typed_error() {
if crate::testing::skip_without_python3(
"a_worker_that_will_not_stay_up_gives_up_with_a_typed_error",
) {
return;
}
let dir = TempDir::new();
let script = dir.path().join("always_dies.py");
std::fs::write(
&script,
"import sys\nprint('fatal: dependency missing', file=sys.stderr, flush=True)\nsys.exit(1)\n",
)
.unwrap();
let config = PluginConfig {
name: "crash-loop".into(),
kind: crate::factory::KIND.into(),
hooks: vec!["tool_pre_invoke".into()],
config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
..Default::default()
};
let plugin = Arc::new(
IsolatedPythonPlugin::from_config(&config)
.expect("config parses")
.with_worker_override(script),
);
plugin.initialize().await.expect("the process launches");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let started = std::time::Instant::now();
let mut gave_up = None;
for _ in 0..(MAX_RESPAWN_ATTEMPTS + 4) {
let mut ctx = PluginContext::new();
let Err(err) = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await else {
panic!("a worker that always dies cannot serve a request");
};
let message = err.to_string();
if message.contains("could not be respawned") {
gave_up = Some(message);
break;
}
}
let message = gave_up
.expect("a permanently failing worker must give up with an error, not retry forever");
assert!(
message.contains(&MAX_RESPAWN_ATTEMPTS.to_string()),
"the error should name the attempt bound: {message}"
);
assert!(
started.elapsed() < std::time::Duration::from_secs(20),
"giving up took too long: {:?}",
started.elapsed()
);
plugin.shutdown().await.unwrap();
}
#[tokio::test]
async fn respawn_is_not_attempted_before_initialize() {
let config = PluginConfig {
name: "uninitialized".into(),
kind: crate::factory::KIND.into(),
hooks: vec!["tool_pre_invoke".into()],
config: Some(serde_json::json!({ "class_name": "my_pkg.Plugin" })),
..Default::default()
};
let plugin = Arc::new(IsolatedPythonPlugin::from_config(&config).unwrap());
let Err(err) = plugin.worker().await else {
panic!("a plugin with no worker must not silently spawn one");
};
let message = err.to_string();
assert!(
message.contains("initialize()"),
"the error should name the lifecycle gap: {message}"
);
assert!(
plugin.worker.read().await.is_none(),
"no worker was started"
);
}
#[tokio::test]
async fn concurrent_invocations_share_one_respawn() {
if crate::testing::skip_without_python3("concurrent_invocations_share_one_respawn") {
return;
}
let (dir, plugin) = adapter_plugin(&["tool_pre_invoke"], &[]).await;
set_mode(&dir, "allow");
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.unwrap();
set_mode(&dir, "die");
let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
await_worker_death(&plugin).await;
set_mode(&dir, "allow");
let mut handles = Vec::new();
for _ in 0..4 {
let plugin = Arc::clone(&plugin);
handles.push(tokio::spawn(async move {
let payload = crate::legacy::ToolPreInvokePayload {
name: "search".into(),
..Default::default()
};
let mut ctx = PluginContext::new();
invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.map(|f| f.continue_processing)
}));
}
for handle in handles {
assert!(handle.await.unwrap().expect("each invocation recovers"));
}
set_mode(&dir, "die");
let mut ctx = PluginContext::new();
let _ = invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx).await;
await_worker_death(&plugin).await;
set_mode(&dir, "allow");
let mut ctx = PluginContext::new();
invoke(&plugin, "tool_pre_invoke", &payload, &mut ctx)
.await
.expect("budget was not exhausted by the concurrent respawn");
plugin.shutdown().await.unwrap();
}
#[test]
fn the_respawn_budget_is_bounded_then_resets_with_time() {
let mut state = RespawnState::new();
for expected in 1..=MAX_RESPAWN_ATTEMPTS {
assert_eq!(state.try_reserve(), Some(expected));
}
assert_eq!(
state.try_reserve(),
None,
"the budget must be bounded, not unlimited"
);
state.record_success();
assert_eq!(state.try_reserve(), Some(1));
let mut state = RespawnState::new();
while state.try_reserve().is_some() {}
state.window_started = Some(
std::time::Instant::now()
- std::time::Duration::from_secs(RESPAWN_BUDGET_RESET_SECS + 1),
);
assert_eq!(
state.try_reserve(),
Some(1),
"an elapsed window must restore the budget"
);
}
#[test]
fn unknown_config_keys_are_tolerated() {
let cfg = VenvConfig::from_plugin_config(&config_with(serde_json::json!({
"class_name": "my_pkg.Minimal",
"some_python_only_setting": true,
})))
.expect("extra keys belong to the Python plugin, not this host");
assert_eq!(cfg.class_name, "my_pkg.Minimal");
}
}