plugin: fix instance lifecycle blockers

This commit is contained in:
Keisuke Hirata 2026-06-20 23:42:44 +09:00
parent 147a600577
commit 870bcc76a5
No known key found for this signature in database
4 changed files with 507 additions and 120 deletions

View File

@ -484,6 +484,10 @@ mod tests {
/// PluginInstanceRegistry. /// PluginInstanceRegistry.
pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0"; pub const PLUGIN_INSTANCE_WORLD: &str = "yoi:plugin/instance@1.0.0";
/// Repository WIT for the current instance world.
pub const INSTANCE_WIT: &str =
include_str!("../../../resources/plugin/wit/yoi-plugin-instance-v1.wit");
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct PluginIngressEvent { pub struct PluginIngressEvent {
pub kind: String, pub kind: String,
@ -531,117 +535,110 @@ pub trait Plugin: Sized + 'static {
#[doc(hidden)] #[doc(hidden)]
pub fn plugin_instance_error(message: impl Into<String>) -> String { pub fn plugin_instance_error(message: impl Into<String>) -> String {
serde_json::json!({ "error": message.into() }).to_string() serde_json::json!({ "error": { "message": message.into() } }).to_string()
} }
/// Export the simple string-json instance ABI used by #[doc(hidden)]
/// `yoi:plugin/instance@1.0.0`. pub fn plugin_instance_status(status: &PluginStatus) -> String {
serde_json::to_string(status).unwrap_or_else(|error| plugin_instance_error(error.to_string()))
}
/// Implement the generated Component Model `Guest` trait for an instance Plugin
/// and export it with the `wit-bindgen` generated `export!` macro.
///
/// The caller must invoke `wit_bindgen::generate!` for the `instance` world
/// first, with `runtime_path: "yoi_plugin_pdk::wit_bindgen::rt"`. That defines
/// the `Guest` trait and `export!` macro in the current module.
#[macro_export] #[macro_export]
macro_rules! export_plugin_instance { macro_rules! export_plugin_instance {
($plugin:ty) => { ($adapter:ident, $plugin:ty) => {
mod __yoi_plugin_instance_export { struct $adapter;
use super::*;
use std::cell::RefCell;
thread_local! { thread_local! {
static INSTANCE: RefCell<Option<$plugin>> = const { RefCell::new(None) }; static YOI_PLUGIN_INSTANCE: ::std::cell::RefCell<::std::option::Option<$plugin>> = const { ::std::cell::RefCell::new(None) };
}
impl Guest for $adapter {
fn start(config_json: ::std::string::String) -> ::std::string::String {
let config = serde_json::from_str(&config_json).unwrap_or(serde_json::Value::Null);
match <$plugin as $crate::Plugin>::start(config) {
Ok(plugin) => {
YOI_PLUGIN_INSTANCE.with(|slot| *slot.borrow_mut() = Some(plugin));
$crate::plugin_instance_status(&$crate::PluginStatus::ready(serde_json::Value::Null))
}
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
} }
#[unsafe(export_name = "start")] fn handle_tool(
pub extern "C" fn __yoi_start( name: ::std::string::String,
_config_json_ptr: *const u8, input_json: ::std::string::String,
_config_json_len: usize, ) -> ::std::string::String {
) -> usize { let input = serde_json::from_str(&input_json).unwrap_or(serde_json::Value::Null);
// This low-level symbol is a placeholder for non-component raw builds. YOI_PLUGIN_INSTANCE.with(|slot| {
// Component builds should bind this macro through generated WIT glue. let mut slot = slot.borrow_mut();
0 let Some(plugin) = slot.as_mut() else {
return $crate::plugin_instance_error("plugin instance has not been started");
};
match plugin.handle_tool(&name, input) {
Ok(output) => output.to_json_string(),
Err(error) => error.into_tool_output().to_json_string(),
}
})
} }
pub struct InstanceGuest; fn handle_ingress(
name: ::std::string::String,
event_json: ::std::string::String,
) -> ::std::string::String {
let event = match serde_json::from_str::<$crate::PluginIngressEvent>(&event_json) {
Ok(event) => event,
Err(error) => return $crate::plugin_instance_error(error.to_string()),
};
YOI_PLUGIN_INSTANCE.with(|slot| {
let mut slot = slot.borrow_mut();
let Some(plugin) = slot.as_mut() else {
return $crate::plugin_instance_error("plugin instance has not been started");
};
match plugin.handle_ingress(&name, event) {
Ok(output) => serde_json::to_string(&output)
.unwrap_or_else(|error| $crate::plugin_instance_error(error.to_string())),
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
})
}
impl InstanceGuest { fn status() -> ::std::string::String {
pub fn start(config_json: String) -> String { YOI_PLUGIN_INSTANCE.with(|slot| {
let config = let slot = slot.borrow();
serde_json::from_str(&config_json).unwrap_or(serde_json::Value::Null); let Some(plugin) = slot.as_ref() else {
match <$plugin as $crate::Plugin>::start(config) { return $crate::plugin_instance_error("plugin instance has not been started");
Ok(plugin) => { };
INSTANCE.with(|slot| *slot.borrow_mut() = Some(plugin)); match plugin.status() {
serde_json::to_string(&$crate::PluginStatus::ready( Ok(status) => $crate::plugin_instance_status(&status),
serde_json::Value::Null, Err(error) => $crate::plugin_instance_error(error.to_string()),
)) }
.unwrap() })
}
fn stop() -> ::std::string::String {
YOI_PLUGIN_INSTANCE.with(|slot| {
let mut slot = slot.borrow_mut();
let Some(plugin) = slot.as_mut() else {
return $crate::plugin_instance_error("plugin instance has not been started");
};
match plugin.stop() {
Ok(status) => {
let output = $crate::plugin_instance_status(&status);
*slot = None;
output
} }
Err(error) => $crate::plugin_instance_error(error.to_string()), Err(error) => $crate::plugin_instance_error(error.to_string()),
} }
} })
pub fn handle_tool(name: String, input_json: String) -> String {
let input =
serde_json::from_str(&input_json).unwrap_or(serde_json::Value::Null);
INSTANCE.with(|slot| {
let mut slot = slot.borrow_mut();
let Some(plugin) = slot.as_mut() else {
return $crate::plugin_instance_error(
"plugin instance has not been started",
);
};
match plugin.handle_tool(&name, input) {
Ok(output) => serde_json::to_string(&output).unwrap(),
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
})
}
pub fn handle_ingress(name: String, event_json: String) -> String {
let event =
match serde_json::from_str::<$crate::PluginIngressEvent>(&event_json) {
Ok(event) => event,
Err(error) => return $crate::plugin_instance_error(error.to_string()),
};
INSTANCE.with(|slot| {
let mut slot = slot.borrow_mut();
let Some(plugin) = slot.as_mut() else {
return $crate::plugin_instance_error(
"plugin instance has not been started",
);
};
match plugin.handle_ingress(&name, event) {
Ok(output) => serde_json::to_string(&output).unwrap(),
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
})
}
pub fn status() -> String {
INSTANCE.with(|slot| {
let slot = slot.borrow();
let Some(plugin) = slot.as_ref() else {
return $crate::plugin_instance_error(
"plugin instance has not been started",
);
};
match plugin.status() {
Ok(status) => serde_json::to_string(&status).unwrap(),
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
})
}
pub fn stop() -> String {
INSTANCE.with(|slot| {
let mut slot = slot.borrow_mut();
let Some(plugin) = slot.as_mut() else {
return $crate::plugin_instance_error(
"plugin instance has not been started",
);
};
match plugin.stop() {
Ok(status) => serde_json::to_string(&status).unwrap(),
Err(error) => $crate::plugin_instance_error(error.to_string()),
}
})
}
} }
} }
export!($adapter);
}; };
} }

View File

@ -67,17 +67,42 @@ pub fn plugin_tool_features(config: &PluginConfig) -> Vec<PluginToolFeature> {
.collect() .collect()
} }
#[derive(Clone, Debug)] #[derive(Clone)]
pub struct PluginToolFeature { pub struct PluginToolFeature {
record: ResolvedPluginRecord, record: ResolvedPluginRecord,
feature_id: FeatureId, feature_id: FeatureId,
registry: PluginInstanceRegistry,
} }
impl PluginToolFeature { impl PluginToolFeature {
pub fn new(record: ResolvedPluginRecord) -> Self { pub fn new(record: ResolvedPluginRecord) -> Self {
let feature_id = FeatureId::new(format!("plugin:{}:tool", record.identity)) let feature_id = FeatureId::new(format!("plugin:{}:tool", record.identity))
.expect("source-qualified plugin identity yields non-empty feature id"); .expect("source-qualified plugin identity yields non-empty feature id");
Self { record, feature_id } Self {
record,
feature_id,
registry: PluginInstanceRegistry::default(),
}
}
fn ensure_instance(&self) -> Result<PluginInstanceHandle, FeatureInstallError> {
self.registry.register(self.record.clone())
}
pub fn instance_status(&self) -> Option<PluginInstanceStatus> {
self.registry.status(&self.record.identity.to_string())
}
pub fn dispatch_ingress(
&self,
ingress_name: &str,
event: PluginIngressEvent,
) -> Result<PluginIngressDispatchReport, PluginWasmError> {
let handle = self
.registry
.handle(&self.record.identity.to_string())
.ok_or_else(|| PluginWasmError::Module("plugin instance is not started".to_string()))?;
handle.deliver_ingress(ingress_name, event)
} }
pub fn origin(&self) -> ToolOrigin { pub fn origin(&self) -> ToolOrigin {
@ -454,7 +479,6 @@ impl FeatureModule for PluginToolFeature {
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
validate_declared_tool_names(&self.record)?; validate_declared_tool_names(&self.record)?;
let registry = PluginInstanceRegistry::default();
let mut instance: Option<PluginInstanceHandle> = None; let mut instance: Option<PluginInstanceHandle> = None;
let mut registered = 0usize; let mut registered = 0usize;
let mut denied = Vec::new(); let mut denied = Vec::new();
@ -476,6 +500,9 @@ impl FeatureModule for PluginToolFeature {
denied.push(message); denied.push(message);
continue; continue;
} }
if instance.is_none() {
instance = Some(self.ensure_instance()?);
}
context.services().provide(ServiceDeclaration::new( context.services().provide(ServiceDeclaration::new(
plugin_service_id(&self.record, &service.name), plugin_service_id(&self.record, &service.name),
self.record.manifest.version.clone(), self.record.manifest.version.clone(),
@ -498,6 +525,8 @@ impl FeatureModule for PluginToolFeature {
); );
context.diagnostics().warning(message.clone()); context.diagnostics().warning(message.clone());
denied.push(message); denied.push(message);
} else if instance.is_none() {
instance = Some(self.ensure_instance()?);
} }
} }
for tool in &self.record.manifest.tools { for tool in &self.record.manifest.tools {
@ -527,7 +556,7 @@ impl FeatureModule for PluginToolFeature {
let tool_instance = match &instance { let tool_instance = match &instance {
Some(instance) => instance.clone(), Some(instance) => instance.clone(),
None => { None => {
let created = registry.register(self.record.clone())?; let created = self.ensure_instance()?;
instance = Some(created.clone()); instance = Some(created.clone());
created created
} }
@ -1939,10 +1968,11 @@ impl PluginInstanceDiagnostic {
} }
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[derive(Clone, Debug, PartialEq, Serialize)]
pub struct PluginInstanceStatus { pub struct PluginInstanceStatus {
pub plugin_ref: String, pub plugin_ref: String,
pub lifecycle: PluginInstanceLifecycleState, pub lifecycle: PluginInstanceLifecycleState,
pub component_status: Option<Value>,
pub diagnostics: Vec<PluginInstanceDiagnostic>, pub diagnostics: Vec<PluginInstanceDiagnostic>,
} }
@ -1998,6 +2028,14 @@ impl PluginInstanceRegistry {
.map(PluginInstanceHandle::status) .map(PluginInstanceHandle::status)
} }
pub fn handle(&self, plugin_ref: &str) -> Option<PluginInstanceHandle> {
self.instances
.lock()
.expect("plugin instance registry poisoned")
.get(plugin_ref)
.cloned()
}
pub fn stop(&self, plugin_ref: &str) -> Result<Option<PluginInstanceStatus>, PluginWasmError> { pub fn stop(&self, plugin_ref: &str) -> Result<Option<PluginInstanceStatus>, PluginWasmError> {
let handle = self let handle = self
.instances .instances
@ -2019,6 +2057,7 @@ impl PluginInstanceHandle {
record, record,
runtime, runtime,
lifecycle: PluginInstanceLifecycleState::Ready, lifecycle: PluginInstanceLifecycleState::Ready,
component_status: None,
diagnostics: Vec::new(), diagnostics: Vec::new(),
}; };
instance.start()?; instance.start()?;
@ -2044,13 +2083,14 @@ impl PluginInstanceHandle {
} }
pub fn status(&self) -> PluginInstanceStatus { pub fn status(&self) -> PluginInstanceStatus {
self.0.lock().expect("plugin instance poisoned").status() let mut instance = self.0.lock().expect("plugin instance poisoned");
instance.status()
} }
pub fn stop(&self) -> Result<PluginInstanceStatus, PluginWasmError> { pub fn stop(&self) -> Result<PluginInstanceStatus, PluginWasmError> {
let mut instance = self.0.lock().expect("plugin instance poisoned"); let mut instance = self.0.lock().expect("plugin instance poisoned");
instance.stop()?; instance.stop()?;
Ok(instance.status()) Ok(instance.snapshot_status())
} }
fn record_diagnostic(&self, diagnostic: PluginInstanceDiagnostic) { fn record_diagnostic(&self, diagnostic: PluginInstanceDiagnostic) {
@ -2065,6 +2105,7 @@ struct PluginInstance {
record: ResolvedPluginRecord, record: ResolvedPluginRecord,
runtime: PluginInstanceRuntime, runtime: PluginInstanceRuntime,
lifecycle: PluginInstanceLifecycleState, lifecycle: PluginInstanceLifecycleState,
component_status: Option<Value>,
diagnostics: Vec<PluginInstanceDiagnostic>, diagnostics: Vec<PluginInstanceDiagnostic>,
} }
@ -2083,7 +2124,8 @@ impl PluginInstance {
self.lifecycle = PluginInstanceLifecycleState::Started; self.lifecycle = PluginInstanceLifecycleState::Started;
} }
PluginInstanceRuntime::ComponentInstance(runtime) => { PluginInstanceRuntime::ComponentInstance(runtime) => {
runtime.start(&self.record)?; let status = runtime.start(&self.record)?;
self.component_status = Some(status);
self.lifecycle = PluginInstanceLifecycleState::Started; self.lifecycle = PluginInstanceLifecycleState::Started;
} }
} }
@ -2200,16 +2242,43 @@ impl PluginInstance {
PluginInstanceRuntime::LegacyToolAdapter => {} PluginInstanceRuntime::LegacyToolAdapter => {}
#[cfg(test)] #[cfg(test)]
PluginInstanceRuntime::TestIngress { .. } => {} PluginInstanceRuntime::TestIngress { .. } => {}
PluginInstanceRuntime::ComponentInstance(runtime) => runtime.stop()?, PluginInstanceRuntime::ComponentInstance(runtime) => {
self.component_status = Some(runtime.stop()?);
}
} }
self.lifecycle = PluginInstanceLifecycleState::Stopped; self.lifecycle = PluginInstanceLifecycleState::Stopped;
Ok(()) Ok(())
} }
fn status(&self) -> PluginInstanceStatus { fn snapshot_status(&self) -> PluginInstanceStatus {
PluginInstanceStatus { PluginInstanceStatus {
plugin_ref: self.record.identity.to_string(), plugin_ref: self.record.identity.to_string(),
lifecycle: self.lifecycle.clone(), lifecycle: self.lifecycle.clone(),
component_status: self.component_status.clone(),
diagnostics: self.diagnostics.clone(),
}
}
fn status(&mut self) -> PluginInstanceStatus {
if let PluginInstanceRuntime::ComponentInstance(runtime) = &mut self.runtime {
match runtime.status() {
Ok(status) => self.component_status = Some(status),
Err(error) => {
self.lifecycle = PluginInstanceLifecycleState::Failed;
self.diagnostics.push(PluginInstanceDiagnostic::new(
PluginInstanceLifecycleState::Failed,
format!(
"plugin component status failed: {}",
error.bounded_message()
),
));
}
}
}
PluginInstanceStatus {
plugin_ref: self.record.identity.to_string(),
lifecycle: self.lifecycle.clone(),
component_status: self.component_status.clone(),
diagnostics: self.diagnostics.clone(), diagnostics: self.diagnostics.clone(),
} }
} }
@ -2230,6 +2299,8 @@ impl PluginInstanceRuntime {
return Ok(Self::LegacyToolAdapter); return Ok(Self::LegacyToolAdapter);
}; };
match runtime.kind.as_str() { match runtime.kind.as_str() {
#[cfg(test)]
"test-ingress" => Ok(Self::TestIngress { calls: 0 }),
PLUGIN_RUNTIME_WASM_KIND => Ok(Self::LegacyToolAdapter), PLUGIN_RUNTIME_WASM_KIND => Ok(Self::LegacyToolAdapter),
PLUGIN_RUNTIME_COMPONENT_KIND PLUGIN_RUNTIME_COMPONENT_KIND
if runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) => if runtime.world.as_deref() == Some(PLUGIN_COMPONENT_INSTANCE_WORLD) =>
@ -2299,7 +2370,7 @@ impl PluginComponentInstanceRuntime {
.map_err(|error| PluginWasmError::Execution(error.to_string())) .map_err(|error| PluginWasmError::Execution(error.to_string()))
} }
fn start(&mut self, record: &ResolvedPluginRecord) -> Result<(), PluginWasmError> { fn start(&mut self, record: &ResolvedPluginRecord) -> Result<Value, PluginWasmError> {
self.reset_fuel()?; self.reset_fuel()?;
let start = self let start = self
.instance .instance
@ -2311,10 +2382,10 @@ impl PluginComponentInstanceRuntime {
)) ))
})?; })?;
let config_json = plugin_config_json(record); let config_json = plugin_config_json(record);
let (_status,) = start let (status,) = start
.call(&mut self.store, (&config_json,)) .call(&mut self.store, (&config_json,))
.map_err(|error| PluginWasmError::Execution(error.to_string()))?; .map_err(|error| PluginWasmError::Execution(error.to_string()))?;
Ok(()) decode_plugin_lifecycle_output("start", &status)
} }
fn handle_tool( fn handle_tool(
@ -2372,7 +2443,7 @@ impl PluginComponentInstanceRuntime {
}) })
} }
fn stop(&mut self) -> Result<(), PluginWasmError> { fn stop(&mut self) -> Result<Value, PluginWasmError> {
self.reset_fuel()?; self.reset_fuel()?;
let stop = self let stop = self
.instance .instance
@ -2383,11 +2454,55 @@ impl PluginComponentInstanceRuntime {
PLUGIN_COMPONENT_INSTANCE_WORLD PLUGIN_COMPONENT_INSTANCE_WORLD
)) ))
})?; })?;
let (_status,) = stop let (status,) = stop
.call(&mut self.store, ()) .call(&mut self.store, ())
.map_err(|error| PluginWasmError::Execution(error.to_string()))?; .map_err(|error| PluginWasmError::Execution(error.to_string()))?;
Ok(()) decode_plugin_lifecycle_output("stop", &status)
} }
fn status(&mut self) -> Result<Value, PluginWasmError> {
self.reset_fuel()?;
let status = self
.instance
.get_typed_func::<(), (String,)>(&mut self.store, "status")
.map_err(|error| {
PluginWasmError::Module(format!(
"component does not export expected `{}` status function: {error}",
PLUGIN_COMPONENT_INSTANCE_WORLD
))
})?;
let (status,) = status
.call(&mut self.store, ())
.map_err(|error| PluginWasmError::Execution(error.to_string()))?;
decode_plugin_lifecycle_output("status", &status)
}
}
fn decode_plugin_lifecycle_output(phase: &str, output: &str) -> Result<Value, PluginWasmError> {
if output.len() > PLUGIN_WASM_MAX_OUTPUT_BYTES {
return Err(PluginWasmError::Output(format!(
"plugin component {phase} output exceeds {} bytes",
PLUGIN_WASM_MAX_OUTPUT_BYTES
)));
}
let value: Value = serde_json::from_str(output).map_err(|error| {
PluginWasmError::Output(format!(
"plugin component {phase} output is not JSON: {error}"
))
})?;
if let Some(error) = value.get("error") {
return Err(PluginWasmError::Execution(format!(
"plugin component {phase} returned error: {}",
bounded_message(error.to_string())
)));
}
if value.get("state").and_then(Value::as_str) == Some("failed") {
return Err(PluginWasmError::Execution(format!(
"plugin component {phase} returned failed status: {}",
bounded_message(value.to_string())
)));
}
Ok(value)
} }
fn plugin_config_json(record: &ResolvedPluginRecord) -> String { fn plugin_config_json(record: &ResolvedPluginRecord) -> String {
@ -3608,6 +3723,188 @@ mod tests {
permissions permissions
} }
fn install_feature(
feature: PluginToolFeature,
) -> (
super::super::FeatureRegistryInstallReport,
Vec<ToolDefinition>,
) {
let mut pending = Vec::new();
let mut hooks = crate::hook::HookRegistryBuilder::new();
let report = super::super::FeatureRegistryBuilder::default()
.with_module(feature)
.install_into_pending(&mut pending, &mut hooks);
(report, pending)
}
#[test]
fn component_lifecycle_rejects_start_error_status() {
let component = component_instance_with_outputs(
br#"{"error":{"message":"boom"}}"#,
br#"{"state":"ready"}"#,
br#"{"state":"stopped"}"#,
br#"{"summary":"tool"}"#,
br#"{"accepted":true}"#,
);
let (_dir, mut record) = resolved_record_with_component(component);
record.manifest.runtime.as_mut().unwrap().world =
Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into());
let error = match PluginInstanceHandle::new(record) {
Ok(_) => panic!("component start error should fail instance creation"),
Err(error) => error,
};
assert!(error.bounded_message().contains("start returned error"));
}
#[test]
fn component_lifecycle_reports_status_and_stop_outputs() {
let component = component_instance_with_outputs(
br#"{"state":"ready","data":{"phase":"start"}}"#,
br#"{"state":"ready","data":{"phase":"status"}}"#,
br#"{"state":"stopped","data":{"phase":"stop"}}"#,
br#"{"summary":"tool"}"#,
br#"{"accepted":true}"#,
);
let (_dir, mut record) = resolved_record_with_component(component);
record.manifest.runtime.as_mut().unwrap().world =
Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into());
let handle = PluginInstanceHandle::new(record).unwrap();
let status = handle.status();
assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Started);
assert_eq!(status.component_status.unwrap()["data"]["phase"], "status");
let stopped = handle.stop().unwrap();
assert_eq!(stopped.lifecycle, PluginInstanceLifecycleState::Stopped);
assert_eq!(stopped.component_status.unwrap()["data"]["phase"], "stop");
}
fn add_service(record: &mut ResolvedPluginRecord, name: &str) {
record.manifest.surfaces.push(PluginSurface::Service);
record.enabled_surfaces.push(PluginSurface::Service);
record
.manifest
.services
.push(manifest::plugin::PluginServiceManifest {
name: name.into(),
description: "test service".into(),
lifecycle: "host-managed".into(),
status_schema: None,
side_effects: Vec::new(),
});
record
.manifest
.permissions
.push(PluginPermission::surface(PluginSurface::Service));
record
.manifest
.permissions
.push(PluginPermission::service(name));
record
.grants
.permissions
.push(PluginPermission::surface(PluginSurface::Service));
record
.grants
.permissions
.push(PluginPermission::service(name));
}
fn add_ingress(record: &mut ResolvedPluginRecord, name: &str) {
record.manifest.surfaces.push(PluginSurface::Ingress);
record.enabled_surfaces.push(PluginSurface::Ingress);
record
.manifest
.ingresses
.push(manifest::plugin::PluginIngressManifest {
name: name.into(),
description: "test ingress".into(),
event_kinds: vec!["test".into()],
input_schema: None,
sources: Vec::new(),
side_effects: Vec::new(),
});
record
.manifest
.permissions
.push(PluginPermission::surface(PluginSurface::Ingress));
record
.manifest
.permissions
.push(PluginPermission::ingress(name));
record
.grants
.permissions
.push(PluginPermission::surface(PluginSurface::Ingress));
record
.grants
.permissions
.push(PluginPermission::ingress(name));
}
#[test]
fn service_only_install_retains_host_managed_instance() {
let mut record = record(Vec::new());
add_service(&mut record, "svc");
record.manifest.runtime = Some(manifest::plugin::PluginRuntimeManifest {
kind: "test-ingress".into(),
entry: None,
abi: None,
component: None,
world: Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()),
});
let feature = PluginToolFeature::new(record);
let (report, _pending) = install_feature(feature.clone());
assert!(
report.reports.iter().all(|report| report.installed),
"{report:#?}"
);
let status = feature.instance_status().expect("service instance started");
assert_eq!(status.lifecycle, PluginInstanceLifecycleState::Started);
}
#[test]
fn installed_ingress_dispatch_uses_retained_shared_instance() {
let mut record = record(vec![tool("shared_tool")]);
add_ingress(&mut record, "shared_ingress");
record.manifest.runtime = Some(manifest::plugin::PluginRuntimeManifest {
kind: "test-ingress".into(),
entry: None,
abi: None,
component: None,
world: Some(PLUGIN_COMPONENT_INSTANCE_WORLD.into()),
});
let feature = PluginToolFeature::new(record);
let (report, pending) = install_feature(feature.clone());
assert!(
report.reports.iter().all(|report| report.installed),
"{report:#?}"
);
let (_meta, tool) = pending
.into_iter()
.map(|definition| definition())
.find(|(meta, _tool)| meta.name == "shared_tool")
.unwrap();
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
.unwrap();
let output = runtime
.block_on(tool.execute(r#"{"first":true}"#, ToolExecutionContext::default()))
.unwrap();
assert!(output.summary.contains("shared_tool"));
let report = feature
.dispatch_ingress(
"shared_ingress",
PluginIngressEvent {
kind: "test".into(),
source: "unit".into(),
payload: serde_json::json!({ "hello": "world" }),
},
)
.unwrap();
assert!(report.accepted);
assert_eq!(report.output["calls"], 1);
}
#[test] #[test]
fn instance_ingress_dispatch_uses_shared_in_process_instance() { fn instance_ingress_dispatch_uses_shared_in_process_instance() {
let mut record = record(vec![tool("shared_tool")]); let mut record = record(vec![tool("shared_tool")]);
@ -3644,6 +3941,7 @@ mod tests {
record, record,
runtime: PluginInstanceRuntime::TestIngress { calls: 0 }, runtime: PluginInstanceRuntime::TestIngress { calls: 0 },
lifecycle: PluginInstanceLifecycleState::Started, lifecycle: PluginInstanceLifecycleState::Started,
component_status: None,
diagnostics: Vec::new(), diagnostics: Vec::new(),
}))); })));
@ -4929,6 +5227,81 @@ input_schema = {{ type = "object", additionalProperties = true }}
(dir, record) (dir, record)
} }
fn component_instance_with_outputs(
start: &[u8],
status: &[u8],
stop: &[u8],
tool: &[u8],
ingress: &[u8],
) -> Vec<u8> {
wat::parse_str(format!(
r#"(component
(core module $m
(memory (export "memory") 1)
(func (export "realloc") (param i32 i32 i32 i32) (result i32)
(if (result i32) (i32.eqz (local.get 0))
(then (i32.const 8192))
(else (local.get 0))))
(data (i32.const 1024) "{}")
(data (i32.const 2048) "{}")
(data (i32.const 3072) "{}")
(data (i32.const 4096) "{}")
(data (i32.const 5120) "{}")
(func $write (param i32 i32)
(i32.store (i32.const 6144) (local.get 0))
(i32.store (i32.const 6148) (local.get 1)))
(func (export "start") (param i32 i32) (result i32)
(call $write (i32.const 1024) (i32.const {}))
(i32.const 6144))
(func (export "status") (result i32)
(call $write (i32.const 2048) (i32.const {}))
(i32.const 6144))
(func (export "stop") (result i32)
(call $write (i32.const 3072) (i32.const {}))
(i32.const 6144))
(func (export "tool") (param i32 i32 i32 i32) (result i32)
(call $write (i32.const 4096) (i32.const {}))
(i32.const 6144))
(func (export "ingress") (param i32 i32 i32 i32) (result i32)
(call $write (i32.const 5120) (i32.const {}))
(i32.const 6144))
)
(core instance $i (instantiate $m))
(alias core export $i "memory" (core memory $mem))
(alias core export $i "realloc" (core func $realloc))
(alias core export $i "start" (core func $start_core))
(alias core export $i "status" (core func $status_core))
(alias core export $i "stop" (core func $stop_core))
(alias core export $i "tool" (core func $tool_core))
(alias core export $i "ingress" (core func $ingress_core))
(type $start_ty (func (param "config-json" string) (result string)))
(type $noarg_ty (func (result string)))
(type $twoarg_ty (func (param "name" string) (param "json" string) (result string)))
(func $start (type $start_ty) (canon lift (core func $start_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(func $status (type $noarg_ty) (canon lift (core func $status_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(func $stop (type $noarg_ty) (canon lift (core func $stop_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(func $tool (type $twoarg_ty) (canon lift (core func $tool_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(func $ingress (type $twoarg_ty) (canon lift (core func $ingress_core) (memory $mem) (realloc $realloc) string-encoding=utf8))
(export "start" (func $start))
(export "status" (func $status))
(export "stop" (func $stop))
(export "handle-tool" (func $tool))
(export "handle-ingress" (func $ingress))
)"#,
wat_bytes(start),
wat_bytes(status),
wat_bytes(stop),
wat_bytes(tool),
wat_bytes(ingress),
start.len(),
status.len(),
stop.len(),
tool.len(),
ingress.len(),
))
.unwrap()
}
fn component_tool_that_returns(output: &[u8]) -> Vec<u8> { fn component_tool_that_returns(output: &[u8]) -> Vec<u8> {
component_tool_with_memory_pages(output, 1) component_tool_with_memory_pages(output, 1)
} }

View File

@ -1,3 +1,5 @@
[workspace]
[package] [package]
name = "example-yoi-instance-plugin" name = "example-yoi-instance-plugin"
version = "0.1.0" version = "0.1.0"

View File

@ -1,6 +1,14 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use yoi_plugin_pdk::wit_bindgen;
use yoi_plugin_pdk::{export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ToolOutput}; use yoi_plugin_pdk::{export_plugin_instance, Plugin, PluginIngressEvent, PluginStatus, ToolOutput};
wit_bindgen::generate!({
world: "instance",
path: "../../../../resources/plugin/wit",
generate_all,
runtime_path: "yoi_plugin_pdk::wit_bindgen::rt",
});
struct ExamplePlugin { struct ExamplePlugin {
calls: u64, calls: u64,
} }
@ -12,14 +20,21 @@ impl Plugin for ExamplePlugin {
fn handle_tool(&mut self, name: &str, input: Value) -> yoi_plugin_pdk::Result<ToolOutput> { fn handle_tool(&mut self, name: &str, input: Value) -> yoi_plugin_pdk::Result<ToolOutput> {
self.calls += 1; self.calls += 1;
Ok(ToolOutput::text(json!({ ToolOutput::json(
"tool": name, format!("{name} handled by shared instance"),
"calls": self.calls, json!({
"input": input "tool": name,
}).to_string())) "calls": self.calls,
"input": input
}),
)
} }
fn handle_ingress(&mut self, name: &str, event: PluginIngressEvent) -> yoi_plugin_pdk::Result<Value> { fn handle_ingress(
&mut self,
name: &str,
event: PluginIngressEvent,
) -> yoi_plugin_pdk::Result<Value> {
Ok(json!({ Ok(json!({
"ingress": name, "ingress": name,
"kind": event.kind, "kind": event.kind,
@ -34,4 +49,4 @@ impl Plugin for ExamplePlugin {
} }
} }
export_plugin_instance!(ExamplePlugin); export_plugin_instance!(ExamplePluginComponent, ExamplePlugin);