Part 6: A Plugin System That Recovers When Plugins Don’t
You’ve built the pieces. A plugin that takes a string and returns a length. A host that loads it. Error handling so plugins can fail gracefully. Resources for stateful handles. Composition for wiring plugins together.
Now let’s build something real.
We’re going to build a plugin system for a text processing pipeline. The host application is a simple tool that reads text, runs it through a chain of plugins, and outputs the result. Plugins can transform text, validate it, or extract information. The host discovers plugins, loads them, handles errors, and chains results.
This is the architecture that motivated the entire series. Without the Component Model, every one of those tasks — passing strings, handling failures, maintaining state across calls — required pointer arithmetic, manual memory management, and unsafe code. With the Component Model, it’s type-safe Rust all the way down.
What We’re Building
A command-line tool called pipeline that:
- Discovers plugin components in a directory
- Loads each plugin and checks its interface
- Runs text through a chain of plugins, passing results between them
- Handles errors from any plugin without crashing
- Composes plugins that depend on each other
The host doesn’t know what the plugins do at compile time. It knows they implement a text-transform interface — they take a string and return a result. That’s the contract. Everything else is up to the plugin.
Step 1: Define the Interface
Every plugin system starts with a contract. Ours is simple: a plugin takes text and returns either transformed text or an error.
// wit/text-transform.wit
package example:text-transform;
interface text-transform {
/// A plugin that transforms text.
transform: func(input: string) -> result<string, transform-error>;
}
world text-transform {
export text-transform;
}
The result<string, transform-error> return type means plugins can fail. A plugin that validates input might reject it. A plugin that calls an external service might time out. The host must handle both cases.
The error type:
interface text-transform {
variant transform-error {
invalid-input(message: string),
processing-failed(message: string),
}
transform: func(input: string) -> result<string, transform-error>;
}
A variant with named cases, each carrying a message. This gives the host structured error information — not “something went wrong” but “the input was invalid because…”
Step 2: Build Three Plugins
We need plugins that do real work to test the system. Three is enough to demonstrate discovery, chaining, and error handling.
Plugin 1: Normalizer
Strips extra whitespace and normalizes line endings. Never fails — every string can be normalized.
// wit/text-transform.wit — same as above
#![allow(unused)]
fn main() {
// src/lib.rs
wit_bindgen::generate!({ path: "wit" });
use exports::example::text_transform::text_transform::{TransformError, Guest};
struct Normalizer;
impl Guest for Normalizer {
fn transform(input: String) -> Result<String, TransformError> {
let normalized: String = input
.lines()
.map(|line| line.split_whitespace().collect::<Vec<_>>().join(" "))
.filter(|line| !line.is_empty())
.collect::<Vec<_>>()
.join("\n");
Ok(normalized)
}
}
export!(Normalizer);
}
Plugin 2: Word Counter
Counts words and returns a summary string. Never fails.
#![allow(unused)]
fn main() {
struct WordCounter;
impl Guest for WordCounter {
fn transform(input: String) -> Result<String, TransformError> {
let word_count = input.split_whitespace().count();
let line_count = input.lines().count();
let char_count = input.chars().count();
Ok(format!("Words: {word_count}, Lines: {line_count}, Chars: {char_count}\n\n{input}"))
}
}
export!(WordCounter);
}
Plugin 3: Validator
Rejects text that’s too short or contains forbidden words. This plugin can fail — that’s the point.
#![allow(unused)]
fn main() {
struct Validator;
impl Guest for Validator {
fn transform(input: String) -> Result<String, TransformError> {
if input.trim().is_empty() {
return Err(TransformError::InvalidInput(
"Input is empty after trimming".to_string(),
));
}
if input.split_whitespace().count() < 3 {
return Err(TransformError::InvalidInput(
"Input must contain at least 3 words".to_string(),
));
}
// Check for forbidden words
let forbidden = ["spam", "abuse"];
for word in input.split_whitespace() {
if forbidden.contains(&word.to_lowercase().as_str()) {
return Err(TransformError::ProcessingFailed(
format!("Forbidden word detected: {word}"),
));
}
}
Ok(input)
}
}
export!(Validator);
}
Build each one:
cargo build --target wasm32-wasip2
Copy the .wasm files to a plugins directory:
mkdir -p plugins
cp target/wasm32-wasip2/debug/normalizer.wasm plugins/
cp target/wasm32-wasip2/debug/word_counter.wasm plugins/
cp target/wasm32-wasip2/debug/validator.wasm plugins/
Step 3: Build the Host
The host is the orchestrator. It discovers plugins, loads them, and chains their output.
One thing you’ll notice: the host state changed. Parts 1–5 used WasiCtx + ResourceTable with a WasiView impl. Here we use WasiP1Ctx instead:
// Parts 1–5: two fields + WasiView impl
struct State {
ctx: WasiCtx,
table: ResourceTable,
}
impl WasiView for State { ... }
// Part 6+: one field, with a WasiView impl that delegates to WasiP1Ctx
struct HostState {
wasi: WasiP1Ctx,
}
impl WasiView for HostState {
fn ctx(&mut self) -> &mut WasiCtx { self.wasi.ctx() }
fn table(&mut self) -> &mut ResourceTable { self.wasi.table() }
}
WasiP1Ctx is the WASI preview1 compatibility layer — it bundles the context and resource table into a single type. If you don’t need separate access to the resource table (and most hosts don’t), this is less boilerplate — one field instead of two, with a WasiView impl that delegates to WasiP1Ctx’s methods. Both approaches work identically from the plugin’s perspective. We’ll use WasiP1Ctx from here on — if you need fine-grained WASI configuration for resources, the two-field approach from earlier parts still works.
#![allow(unused)]
fn main() {
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use wasmtime::component::{Component, Linker};
use wasmtime::{Engine, Store};
use wasmtime_wasi::preview1::WasiP1Ctx;
use wasmtime_wasi::{WasiCtxBuilder, WasiView, WasiCtx, ResourceTable};
// Generated bindings for the text-transform interface.
// The `TextTransform` type and `TransformError` enum are generated by `bindgen!`.
// The struct name comes from the `world` declaration — `world text-transform`
// generates `TextTransform`. If the name doesn't match, add `world: "text-transform"`
// to the bindgen! call to be explicit.
wasmtime::component::bindgen!({
path: "wit/text-transform.wit",
});
struct HostState {
wasi: WasiP1Ctx,
}
// WasiP1Ctx implements WasiView, but the component-model add_to_linker_sync
// function needs WasiView on the *store data type* (HostState), not on
// WasiP1Ctx directly. So we delegate:
impl WasiView for HostState {
fn ctx(&mut self) -> &mut WasiCtx { self.wasi.ctx() }
fn table(&mut self) -> &mut ResourceTable { self.wasi.table() }
}
// WasiP1Ctx bundles WasiCtx + ResourceTable (see above for the comparison)
fn create_engine() -> Result<Engine> {
// Engine::default() enables the component model automatically in
// wasmtime 29+. If you need custom configuration (memory limits,
// fuel metering), use Config::new() + Engine::new(&config).
// Note: Engine is the same wasmtime::Engine for both core WASM and the
// component model — there's no wasmtime::component::Engine. The component
// model uses the same engine instance.
Ok(Engine::default())
}
fn create_store(engine: &Engine) -> Result<Store<HostState>> {
let wasi = WasiCtxBuilder::new()
.build_p1(); // build_p1() is the preview1 counterpart to .build()
// — it returns a WasiP1Ctx instead of a WasiCtx
Ok(Store::new(engine, HostState { wasi }))
}
}
Discovering Plugins
The host scans a directory for .wasm files. This is the simplest discovery mechanism — in a real system you might use a manifest file, a registry, or a configuration file.
#![allow(unused)]
fn main() {
fn discover_plugins(plugin_dir: &Path) -> Result<Vec<PathBuf>> {
let mut plugins = Vec::new();
for entry in fs::read_dir(plugin_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "wasm") {
plugins.push(path);
}
}
// Sort for deterministic ordering — a real system would use a manifest
plugins.sort();
Ok(plugins)
}
}
Loading a Plugin
Loading means: create the engine, instantiate the component, and get a typed handle to the transform function.
#![allow(unused)]
fn main() {
fn load_plugin(
engine: &Engine,
path: &Path,
) -> Result<(Store<HostState>, TextTransform)> {
let component = Component::from_file(engine, path)
.with_context(|| format!("Failed to load component from {:?}", path))?;
let mut linker = Linker::new(engine);
// Add WASI to the linker so plugins can use println!, file I/O, etc.
// We use the top-level wasmtime_wasi::add_to_linker_sync (not the
// preview1 version) because we have a component Linker. The preview1
// version takes a core WASM linker — the types don't match.
wasmtime_wasi::add_to_linker_sync(&mut linker)?;
let mut store = create_store(engine)?;
let instance = linker.instantiate(&mut store, &component)
.with_context(|| format!("Failed to instantiate {:?}", path))?;
let bindings = TextTransform::new(&mut store, &instance)
.with_context(|| format!("Component {:?} doesn't export text-transform", path))?;
Ok((store, bindings))
}
}
The TextTransform::new call does two things: it checks that the component exports the text-transform interface, and it gives us a typed handle. If a .wasm file in the plugins directory doesn’t implement the interface, the load fails with a clear error message. The host skips it and continues — one bad plugin doesn’t take down the system.
Running the Pipeline
The pipeline runs text through each plugin in sequence. Each plugin’s output becomes the next plugin’s input. If any plugin fails, the pipeline stops and reports the error.
#![allow(unused)]
fn main() {
use anyhow::{Result, anyhow};
fn run_pipeline(
engine: &Engine,
plugin_paths: &[PathBuf],
input: &str,
) -> Result<String> {
let mut text = input.to_string();
let mut processed_by = Vec::new();
for path in plugin_paths {
let name = path.file_stem()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let (mut store, bindings) = match load_plugin(engine, path) {
Ok(result) => result,
Err(e) => {
eprintln!("⚠ Skipping {:?}: {}", path, e);
continue; // Skip broken plugins, don't crash
}
};
match bindings.call_transform(&mut store, &text) {
Ok(Ok(transformed)) => {
println!("✓ {} — {} chars → {} chars", name, text.len(), transformed.len());
text = transformed;
processed_by.push(name);
}
Ok(Err(e)) => {
// The plugin rejected the input
match e {
TransformError::InvalidInput(msg) => {
eprintln!("✗ {} rejected input: {}", name, msg);
}
TransformError::ProcessingFailed(msg) => {
eprintln!("✗ {} failed: {}", name, msg);
}
}
return Err(anyhow!(
"Pipeline stopped at {} — input rejected", name
));
}
Err(e) => {
// Wasm-level error (trap, out of memory, etc.)
eprintln!("✗ {} crashed: {}", name, e);
return Err(anyhow!(
"Pipeline stopped at {} — plugin crashed", name
));
}
}
}
println!("Processed by: {}", processed_by.join(" → "));
Ok(text)
}
}
Three outcomes for each plugin:
- Success — output flows to the next plugin
- Application error — the plugin returned
Err, the pipeline stops with a structured message - Wasm error — the plugin trapped (crashed), the pipeline stops with a system error
This is the result type from Part 4 doing real work. The host doesn’t need to parse error strings or check exit codes — it gets a typed TransformError variant.
Putting It All Together
fn main() -> Result<()> {
let plugin_dir = PathBuf::from(
std::env::args().nth(1).unwrap_or_else(|| "plugins".to_string())
);
if !plugin_dir.exists() {
eprintln!("Plugin directory {:?} not found", plugin_dir);
std::process::exit(1);
}
let engine = create_engine()?;
let plugins = discover_plugins(&plugin_dir)?;
if plugins.is_empty() {
eprintln!("No plugins found in {:?}", plugin_dir);
std::process::exit(1);
}
println!("Found {} plugin(s):", plugins.len());
for p in &plugins {
println!(" - {}", p.file_stem().unwrap_or_default().to_string_lossy());
}
println!();
// Read input from stdin
let input = std::io::read_to_string(std::io::stdin())
.context("Failed to read input from stdin")?;
let output = run_pipeline(&engine, &plugins, &input)?;
print!("{}", output);
Ok(())
}
Step 4: Run It
Create some test input:
echo " hello world this is a test " | cargo run -- plugins
Output:
Found 3 plugin(s):
- normalizer
- validator
- word_counter
✓ normalizer — 40 chars → 26 chars
✓ validator — 26 chars → 26 chars
✓ word_counter — 26 chars → 57 chars
Processed by: normalizer → validator → word_counter
Words: 6, Lines: 1, Chars: 26
hello world this is a test
The normalizer strips whitespace, the validator checks word count (6 words — passes the 3-word minimum), and the word counter adds statistics.
Now test with invalid input:
echo "hi" | cargo run -- plugins
Output:
Found 3 plugin(s):
- normalizer
- validator
- word_counter
✓ normalizer — 2 chars → 2 chars
✗ validator rejected input: Input must contain at least 3 words
Error: Pipeline stopped at validator — input rejected
The validator catches the short input and the pipeline stops. The word counter never runs — it would have produced misleading statistics on invalid input.
Step 5: Add a Resource Plugin
Remember resources from Part 4? They’re for stateful handles. Let’s add a plugin that maintains a running word count across multiple inputs.
First, extend the WIT interface with a resource.
Important: Adding
running-counterto thetext-transforminterface is a breaking change — every plugin that implementstext-transformmust now also implementrunning-counter. Our normalizer, word counter, and validator don’t. In practice, you’d define a separate interface for the resource (e.g.,interface counter { resource running-counter { ... } }) or make the resource optional. We’re extending the same interface here to keep the example simple.If you’re building along and update the WIT file, the existing plugins will fail to compile —
export!(Normalizer)will complain thatGuestRunningCounteris unimplemented. You can either add a no-op implementation to each plugin (fn new() -> Self { unimplemented!() }and friends — they’ll never be called since those plugins aren’t loaded for the resource path), or skip rebuilding the Step 3 plugins after this WIT change. Therunning-counterresource only matters for the new plugin shown below.
interface text-transform {
variant transform-error {
invalid-input(message: string),
processing-failed(message: string),
}
transform: func(input: string) -> result<string, transform-error>;
/// A running word counter that accumulates stats across calls.
resource running-counter {
constructor();
count: func(input: string) -> string;
total-words: func() -> u32;
}
}
world text-transform {
export text-transform;
}
The plugin implementation:
#![allow(unused)]
fn main() {
use std::cell::RefCell;
use exports::example::text_transform::text_transform::{Guest, GuestRunningCounter, TransformError};
/// We name this `RunningCounterImpl` (not `RunningCounter`) because wit-bindgen
/// generates a `RunningCounter` type for the resource handle — the same naming
/// conflict we saw in Part 4 with `KvStoreImpl`. Using the same name would
/// cause a compilation error.
struct RunningCounterImpl {
total: RefCell<u32>,
}
// The world exports the entire text-transform interface, so the Guest trait
// requires implementing both `transform` and the `running-counter` resource.
// This plugin exists for the resource — the `transform` method is a pass-through.
impl Guest for RunningCounterImpl {
// The associated type tells wit-bindgen which Rust type implements the resource's methods.
// Same pattern as Part 4's `type KvStore = KvStoreImpl;`.
type RunningCounter = RunningCounterImpl;
fn transform(input: String) -> Result<String, TransformError> {
// This plugin is used via its resource, not the transform function.
// A real system would put the resource in a separate interface.
Ok(input)
}
}
impl GuestRunningCounter for RunningCounterImpl {
fn new() -> Self {
RunningCounterImpl {
total: RefCell::new(0),
}
}
fn count(&self, input: String) -> String {
let words = input.split_whitespace().count() as u32;
*self.total.borrow_mut() += words;
let total = *self.total.borrow();
format!("This: {words}, Running total: {total}\n\n{input}")
}
fn total_words(&self) -> u32 {
*self.total.borrow()
}
}
export!(RunningCounterImpl);
}
RefCell for interior mutability — resource methods take &self, as we discussed in Part 4. The Component Model can’t track exclusive borrows across the Wasm boundary, so RefCell is the standard pattern.
Why does
RunningCounterImplimplementGuesttoo? The WIT world exports the entiretext-transforminterface — bothtransformandrunning-counter. wit-bindgen generates aGuesttrait that requires implementingtransformand an associated type for the resource, plus a separateGuestRunningCountertrait for the resource methods. A component must implement both. Ourtransformis a no-op pass-through — this plugin exists for the resource, not the function. As the note at the top of this section explains, a real system would put the resource in its own interface to avoid this coupling.
A note on the output format: Both
WordCounter::transformandRunningCounter::countprepend statistics to the input text. If you chain them in a pipeline — normalize, then word-count, then running-count — you’d get doubled statistics (the word count output becomes the running counter’s input). In a real system, you’d return structured data (the text plus the stats separately) instead of concatenating everything into one string. We’re concatenating everything into one string to keep the focus on the resource pattern, not the output format.
On the host side, the resource handle is generated by wit-bindgen:
#![allow(unused)]
fn main() {
// After loading the plugin with TextTransform::new(...)
let counter = bindings.running_counter()(&mut store)
.context("Plugin doesn't export running-counter")?;
// Count words in multiple inputs
let result1 = counter.call_count(&mut store, "hello world")?;
let result2 = counter.call_count(&mut store, "foo bar baz")?;
// Get the running total
let total = counter.call_total_words(&mut store)?;
println!("Total words across all calls: {total}"); // → 5
}
The resource handle is tied to the store’s lifetime. When the store is dropped, the Component Model automatically calls the resource’s destructor on the plugin side. No manual cleanup.
What We Built
Let’s step back and see what the Component Model gave us:
| Problem | Without the Component Model | With the Component Model |
|---|---|---|
| Pass strings across Wasm | Allocate Wasm memory, write bytes, pass pointer, read back | func(input: string) -> string |
| Handle plugin errors | Check return codes or parse error strings | result<string, transform-error> with typed variants |
| Stateful plugin handles | Raw pointer + manual lifecycle management | resource running-counter with automatic cleanup |
| Chain plugins | Host mediates every call, passing output to next input | Host chains plugins; composition via wac compose is an alternative (Part 5) |
| Discover plugins at runtime | Parse Wasm exports manually | Generated bindings check the interface for you |
Five problems, five solutions, zero unsafe code on the host side.
What This Means
Typed bindings are the difference between “it works” and “it’s maintainable.” Raw export_index lookups and stringly-typed function calls will get you through a demo. Generated bindings — TextTransform::new, bindings.call_transform — turn the WIT contract into Rust types the compiler checks for you.
result types make error handling part of the contract, not an afterthought. The WIT file says result<string, transform-error>. The generated Rust code returns Result<String, TransformError>. You can’t forget to handle the error case — the type system won’t let you.
Resources give plugins state without giving them unsafe. The RunningCounterImpl holds a RefCell<u32> behind &self methods. The host gets a handle it can call methods on. Neither side writes unsafe pointer arithmetic. The Component Model handles the lifetime and cleanup.
File-based discovery is the starting point, not the endpoint. Scanning a directory for .wasm files works for local tools. A real plugin system needs a manifest, a registry, or at minimum a configurable pipeline order — something that says which plugins to load and in what order.
Store-per-plugin means resources don’t persist across pipeline stages. Our run_pipeline creates a new store for each plugin and drops it at the end of the loop. A RunningCounter created in one iteration is gone by the next. A real system keeps stores alive for the plugin’s lifetime.
Design Decisions
A few choices worth discussing, because you’ll face them in any real plugin system.
File-based Discovery
Scanning a directory for .wasm files is the simplest discovery mechanism. It works for local tools. For distributed systems, you’d want a manifest file (plugins.json or plugins.toml) that lists plugins by name and version, not file path alone. Or a registry like Warg that resolves plugin names to versioned components. Or a configuration file that specifies the pipeline order, rather than relying on filesystem sort order.
Pipeline Order
We process plugins in alphabetical order by filename. This is deterministic but not necessarily correct. In our demo, alphabetical sort happens to run the validator before the word counter — but that’s coincidence, not design. Rename the validator to z_validator and the order breaks. A real system needs intentional ordering, not filename luck. Manifest-based ordering lists plugins in the order they should run. Dependency declaration lets each plugin declare what it needs (like WIT import), and the host resolves the order. Or the user specifies the order on the command line or in a config file.
Error Strategy
We stop the pipeline on the first error. An alternative is error collection — run all plugins, collect all errors, and report them together. This is better for validation (you want to see all the problems, not only the first one). You could implement it by changing run_pipeline to continue on Err and collect errors into a Vec<TransformError>.
Plugin Isolation
Each plugin gets its own Store. This gives you memory isolation (a plugin can’t access another plugin’s memory), error isolation (a trap in one plugin doesn’t crash the host or other plugins), and resource isolation (each plugin’s resources are cleaned up when its store is dropped). This is the right default. In a production system, you might want to share a store between plugins for performance (avoids copying data between stores), but that trades isolation for speed.
Going Further
-
Hot reloading: Watch the plugins directory for changes and reload plugins without restarting the host. The Component Model makes this safe — instantiate a new component and swap the handle. The core pattern is simple:
#![allow(unused)] fn main() { fn reload_plugin(&mut self, name: &str) -> Result<()> { let wasm_path = self.plugin_dir.join(format!("{name}.wasm")); let component = Component::from_file(&self.engine, &wasm_path)?; let mut store = self.create_store(); let instance = self.linker.instantiate(&mut store, &component)?; // Note: This sketch assumes PluginHost has a shared `linker` field. // The load_plugin code earlier in this part creates a new Linker per // call — you'd need to restructure PluginHost to hold a shared Linker // and reuse it across loads. let bindings = TextTransform::new(&mut store, &instance)?; // Swap: the old store and bindings are dropped, // the new ones take their place in the HashMap. self.plugins.insert(name.to_string(), (store, bindings)); Ok(()) } }The old
(store, bindings)is dropped when the new one is inserted into the HashMap. Any in-flight calls on the old handles will complete before the drop — Wasm stores are single-threaded, so there’s no data race. Resources held by the old store get their destructors called on drop. -
Plugin permissions. Use WASI’s capability-based security to limit what plugins can do — a text transform plugin probably doesn’t need filesystem access.
-
Async processing. The Component Model’s async proposal (streams, futures) would let plugins process large inputs without buffering everything in memory.
-
Versioning. WIT package versioning ensures the host and plugins agree on the interface — a plugin built for
text-transform@0.2.0might not work with a host expecting0.1.0.
Note: The Component Model’s async support and versioning features are still evolving. As of May 2026, wasmtime’s Component Model async support is still in development — check the Component Model specification and the wasmtime repo for the current status.
Next: Part 7 — Testing Across the Boundary — A plugin system is only as trustworthy as its tests. But testing Wasm components is different from testing normal Rust — you’re testing across a boundary that involves serialization, code generation, and a runtime. We’ll write contract tests that verify WIT expectations, integration tests that load the actual .wasm, and pipeline tests that exercise the full chain.