Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Part 4: When Plugins Crash and Files Stay Open — Result Types and Resources in WIT

Our plugins so far have been kind. length always succeeds — every string has a length. process always succeeds — you can always replace “WASM” with “Wasm”. Real plugins aren’t so forgiving. A plugin that parses configuration might encounter invalid syntax. A plugin that fetches data might hit a network error. A plugin that processes images might run out of memory.

Without the Component Model, error handling across the Wasm boundary was a mess. You’d encode error codes as integers, or write error messages into a shared memory buffer and hope the host reads them before overwriting, or return a sentinel value and pray the caller checks it. Every plugin invented its own convention, and none of them were type-safe.

The Component Model has a better answer: WIT result types. They work like Rust’s Result — a value that’s either ok or err — and wit-bindgen generates the right Rust types on both sides. The host can’t forget to check for errors because the type system won’t let it.

And then there’s the harder problem: what if your plugin needs to hand the host a handle to something — an open file, a database connection, a running process? You can’t serialize a TCP connection into bytes and pass it across the Wasm boundary. That’s what WIT resources are for: typed handles that let the host and plugin share ownership of stateful things without either side needing to know the implementation details.

Two problems, two WIT features. result types make errors type-safe — the host must handle the error case, because the generated Rust code won’t compile otherwise. resource types make stateful handles possible — the host holds a typed token, the plugin holds the implementation, and the Component Model connects them. Without both, you can’t build a real plugin system.

Result Types

Let’s build a plugin that parses a simple configuration format. It’ll either return the parsed config or an error describing what went wrong.

Create the workspace:

mkdir -p part-4/plugin/wit

Write part-4/plugin/wit/world.wit:

package example:plugin;

world example-plugin {
    record config-entry {
        key: string,
        value: string,
    }

    record parse-error {
        line: u32,
        message: string,
    }

    export parse: func(input: string) -> result<list<config-entry>, parse-error>;
}

The result<list<config-entry>, parse-error> type says: parse either succeeds with a list of key-value pairs, or fails with a parse-error that tells you which line went wrong and why. The host doesn’t have to guess — the type system forces it to handle both cases.

The Plugin

cd part-4/plugin
cargo init --lib

Cargo.toml:

[package]
name = "example-plugin"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
wit-bindgen = "0.41"

src/lib.rs:

#![allow(unused)]
fn main() {
wit_bindgen::generate!({
    world: "example-plugin",
});

struct ExamplePlugin;

impl Guest for ExamplePlugin {
    fn parse(input: String) -> Result<Vec<ConfigEntry>, ParseError> {
        let mut entries = Vec::new();
        for (i, line) in input.lines().enumerate() {
            let line = line.trim();
            // Skip blank lines and comments
            if line.is_empty() || line.starts_with('#') {
                continue;
            }
            // Each valid line is: key = value
            let Some((key, value)) = line.split_once('=') else {
                return Err(ParseError {
                    line: (i + 1) as u32,
                    message: format!("expected 'key = value', got: {line}"),
                });
            };
            entries.push(ConfigEntry {
                key: key.trim().to_string(),
                value: value.trim().to_string(),
            });
        }
        Ok(entries)
    }
}

export!(ExamplePlugin);
}

Notice the return type: Result<Vec<ConfigEntry>, ParseError>. That’s a regular Rust Result. wit-bindgen maps WIT’s result<ok, err> directly to Rust’s Result<Ok, Err>. You write normal Rust error-handling code, and the Component Model takes care of passing it across the boundary.

Build it:

cargo build --target wasm32-wasip2

The Host

cd part-4/host
cargo init

Cargo.toml:

[package]
name = "example-host"
version = "0.1.0"
edition = "2021"

[dependencies]
wasmtime = "29"
wasmtime-wasi = "29"
anyhow = "1"

src/main.rs:

use anyhow::Result;
use wasmtime::{Engine, Store};
use wasmtime::component::{Component, Linker};
use wasmtime_wasi::{WasiCtxBuilder, WasiCtx, WasiView, ResourceTable};

wasmtime::component::bindgen!({
    world: "example-plugin",
    path: "../plugin/wit",
});

struct State {
    wasi: WasiCtx,
    table: ResourceTable,
}

impl WasiView for State {
    fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi }
    fn table(&mut self) -> &mut ResourceTable { &mut self.table }
}

fn main() -> Result<()> {
    let engine = Engine::default();
    let mut linker = Linker::new(&engine);

    wasmtime_wasi::add_to_linker_sync(&mut linker)?;

    let state = State {
        wasi: WasiCtxBuilder::new().build(),
        table: ResourceTable::new(),
    };
    let mut store = Store::new(&engine, state);

    let bytes = std::fs::read("../plugin/target/wasm32-wasip2/debug/example_plugin.wasm")?;
    let component = Component::new(&engine, bytes)?;

    let instance = linker.instantiate(&mut store, &component)?;
    let plugin = ExamplePlugin::new(&mut store, &instance)?;

    // --- Valid config ---
    let valid = "\
host = localhost
port = 8080
This is a comment
timeout = 30
";

    match plugin.call_parse(&mut store, valid)? {
        Ok(entries) => {
            println!("Parsed {} entries:", entries.len());
            for entry in &entries {
                println!("  {} = {}", entry.key, entry.value);
            }
        }
        Err(e) => {
            println!("Parse error on line {}: {}", e.line, e.message);
        }
    }

    // --- Invalid config ---
    let invalid = "\
host = localhost
this line has no equals sign
port = 8080
";

    match plugin.call_parse(&mut store, invalid)? {
        Ok(entries) => {
            println!("Parsed {} entries:", entries.len());
        }
        Err(e) => {
            println!("Parse error on line {}: {}", e.line, e.message);
        }
    }

    Ok(())
}

Run it:

cargo run
# Parsed 3 entries:
#   host = localhost
#   port = 8080
#   timeout = 30
# Parse error on line 2: expected 'key = value', got: this line has no equals sign

The host doesn’t check a return code. It doesn’t parse an error string. It gets a Result from call_parse, and Rust’s match expression forces it to handle both cases. If you forget the Err arm, the compiler tells you. That’s the Component Model working as advertised: the WIT type system becomes the Rust type system on both sides of the boundary.

result Without an Error Type

WIT also supports partial and bare result types. The full form is result<ok-type, error-type>, but you can leave either side out:

  • result (bare) → Result<(), ()> — “did it work or not” with no details on either side
  • result<T> (ok type only) → Result<T, ()> — a value on success, no diagnostic on failure
  • result<_, E> (error type only) → Result<(), E> — no meaningful return on success, but a specific error on failure

For example, a validation function that doesn’t return a value on success but has a typed error:

export validate: func(input: string) -> result<_, parse-error>;

The _ means “no ok-type” — the Rust side gets Result<(), ParseError>.

The bare result is the simplest case — you don’t need to say why it failed, only that it did.

In WIT:

export validate: func(input: string) -> result;

On the plugin side:

#![allow(unused)]
fn main() {
fn validate(input: String) -> Result<(), ()> {
    if input.is_empty() {
        Err(())  // Invalid, but we don't say why
    } else {
        Ok(())
    }
}
}

On the host side, you still have to handle both cases — Ok(()) means it worked, Err(()) means it didn’t.

When would you use this? A plugin that processes data might return result to signal success or failure without providing diagnostic details. It’s a simpler contract for simpler situations. Use result<T, E> when the host can meaningfully act on the error information. Use bare result when all the host needs to know is “try something else.”

Resources: Stateful Handles Across the Boundary

Result types handle the “something went wrong” case. But there’s another case the Component Model needs to solve: what if the plugin creates something stateful that the host needs to hold onto?

Consider a plugin that opens a connection to a database. The host calls connect(), the plugin opens the connection, and… then what? The host can’t hold a Rust Connection object — that lives in the plugin’s memory space. The host can’t receive a serialized connection — you can’t serialize a TCP socket. What the host needs is a handle: a token that says “I own connection #3” without knowing or caring what connection #3 actually is.

That’s what WIT resources are. A resource is a typed handle. The host holds the handle. The plugin holds the implementation. When the host calls a method on the handle, the Component Model routes it to the plugin’s implementation automatically.

Let’s build a plugin that manages a simple key-value store. The host can create a store, put values in it, and get values out — all through resource handles.

The WIT Interface

Write part-4/plugin/wit/world.wit:

package example:plugin;

world example-plugin {
    resource kv-store {
        constructor();

        get: func(key: string) -> option<string>;
        put: func(key: string, value: string);
        delete: func(key: string);
    }

    export open-store: func() -> kv-store;
}

A resource kv-store defines a handle type with methods. The constructor creates a new store. get, put, and delete operate on it. The host never sees the HashMap inside — it only sees the kv-store handle.

The option<string> return type on get is WIT’s equivalent of Rust’s Option<String> — the key might not exist. Like result, wit-bindgen maps option directly to Rust’s Option. The type system keeps you honest.

export open-store: func() -> kv-store is a top-level function that creates and returns a new kv-store handle. The host calls this to get a store, then calls methods on the store. We could use the resource’s constructor() directly instead of a factory function — but the factory function pattern is more general (it works even when construction can fail and return a result), so we’ll use it consistently throughout this tutorial.

⚠️ Hyphenated WIT names and the linker: Earlier drafts used create-store here and count-clean in Part 5. Hyphenated function names produce cabi_post_create-store symbols that can break the linker version script parser in some Rust toolchain versions. We renamed create-store to open-store and parse-config (in the companion code) to parse to avoid the issue. Part 5 keeps count-clean because renaming it would cascade through the .wac composition files — the wasm32-wasip2 target (the intended build target) compiles correctly. If you hit a linker error on a non-standard target, the fix is the same: rename to a single word or use underscores.

Companion code note: The runnable code in part-4/ uses an interface storage block to define the resource, which is the idiomatic pattern for multi-resource APIs. The WIT there looks like interface storage { resource kv-store { ... } } with export storage; instead of the inline resource + export open-store shown here. The generated host API differs slightly (the interface pattern adds a namespace level: plugin.storage().kv_store() vs plugin.call_open_store()), but the concept is identical — both give you a typed resource handle to call methods on. The inline pattern is shown here because it’s simpler to explain; the interface pattern is what you’d use in production.

The Plugin

⚠️ Name conflict with generated types: When you use the inline WIT pattern (resource kv-store { ... }), wit-bindgen generates a KvStore handle type for the resource. If you also name your implementation struct KvStore, you’ll get a compilation error — two types with the same name in the same scope. The code below uses KvStoreImpl to avoid this. If you use the interface pattern (as the companion code does), wit-bindgen namespaces the generated type and the conflict doesn’t arise.

src/lib.rs:

#![allow(unused)]
fn main() {
use std::cell::RefCell;
use std::collections::HashMap;

wit_bindgen::generate!({
    world: "example-plugin",
});

/// The actual data behind the kv-store resource.
/// Each host-side handle points to one of these.
struct KvStoreInner {
    data: HashMap<String, String>,
}

/// The resource implementation.
/// `GuestKvStore` is generated by wit-bindgen — it's the trait
/// that maps WIT resource methods to Rust methods.
///
/// We name this `KvStoreImpl` (not `KvStore`) because wit-bindgen
/// generates a `KvStore` type for the resource handle. Using the same
/// name would cause a compilation error.
pub struct KvStoreImpl {
    inner: RefCell<KvStoreInner>,
}

impl GuestKvStore for KvStoreImpl {
    fn new() -> Self {
        KvStoreImpl {
            inner: RefCell::new(KvStoreInner {
                data: HashMap::new(),
            }),
        }
    }

    fn get(&self, key: String) -> Option<String> {
        self.inner.borrow().data.get(&key).cloned()
    }

    fn put(&self, key: String, value: String) {
        self.inner.borrow_mut().data.insert(key, value);
    }

    fn delete(&self, key: String) {
        self.inner.borrow_mut().data.remove(&key);
    }
}

struct ExamplePlugin;

impl Guest for ExamplePlugin {
    type KvStore = KvStoreImpl;

    fn open_store() -> KvStoreImpl {
        KvStoreImpl::new()
    }
}

export!(ExamplePlugin);
}

The type KvStore = KvStoreImpl; line tells wit-bindgen which Rust type implements the resource’s methods. When you define a resource in WIT, wit-bindgen generates a host-side type and a guest-side trait — and the world’s Guest trait needs to know which type fills the resource role. That’s the associated type. The plugin (guest) implements the trait. The host receives the generated type. The Component Model runtime connects them automatically: when the host calls kv_store.put("key", "value"), the runtime routes it to the plugin’s put method.

Resource methods take &self, not &mut self. This is a Component Model constraint — resource methods are individual function calls across the boundary, and the Component Model can’t track exclusive borrows across that boundary. If you need interior mutability (and you usually do), RefCell is the standard pattern. That’s why KvStoreInner is wrapped in RefCell<KvStoreInner> — the &self methods can still mutate by borrowing through the RefCell.

Note: The exact generated trait names and the export! macro usage depend on your wit-bindgen version. In 0.41+, resources generate Guest<ResourceName> traits. If your version uses different names, check the generated code in target/ for the exact trait signatures.

Build:

cargo build --target wasm32-wasip2

The Host

src/main.rs:

use anyhow::Result;
use wasmtime::{Engine, Store};
use wasmtime::component::{Component, Linker};
use wasmtime_wasi::{WasiCtxBuilder, WasiCtx, WasiView, ResourceTable};

wasmtime::component::bindgen!({
    world: "example-plugin",
    path: "../plugin/wit",
});

struct State {
    wasi: WasiCtx,
    table: ResourceTable,
}

impl WasiView for State {
    fn ctx(&mut self) -> &mut WasiCtx { &mut self.wasi }
    fn table(&mut self) -> &mut ResourceTable { &mut self.table }
}

fn main() -> Result<()> {
    let engine = Engine::default();
    let mut linker = Linker::new(&engine);

    wasmtime_wasi::add_to_linker_sync(&mut linker)?;

    let state = State {
        wasi: WasiCtxBuilder::new().build(),
        table: ResourceTable::new(),
    };
    let mut store = Store::new(&engine, state);

    let bytes = std::fs::read("../plugin/target/wasm32-wasip2/debug/example_plugin.wasm")?;
    let component = Component::new(&engine, bytes)?;

    let instance = linker.instantiate(&mut store, &component)?;
    let plugin = ExamplePlugin::new(&mut store, &instance)?;

    // Create a key-value store — this returns a resource handle
    let kv = plugin.call_open_store(&mut store)?;

    // Put some values
    kv.call_put(&mut store, "name", "wasm")?;
    kv.call_put(&mut store, "version", "2.0")?;

    // Get a value
    match kv.call_get(&mut store, "name")? {
        Some(value) => println!("name = {value}"),
        None => println!("name not found"),
    }

    // Get a missing value — option<string> means the host handles None
    match kv.call_get(&mut store, "missing")? {
        Some(value) => println!("missing = {value}"),
        None => println!("missing key not found"),
    }

    // Delete a value
    kv.call_delete(&mut store, "version")?;

    // Verify it's gone
    match kv.call_get(&mut store, "version")? {
        Some(value) => println!("version = {value}"),
        None => println!("version was deleted"),
    }

    Ok(())
}

Run it:

cargo run
# name = wasm
# missing key not found
# version was deleted

The host holds a KvStore handle. It calls methods on it — put, get, delete — like it’s a local object. But each call is crossing the Wasm boundary. The host doesn’t know or care that there’s a HashMap on the other side. It calls methods on a typed handle and gets typed results back — the same ergonomics as a local object, with the Component Model handling all the routing.

When the handle goes out of scope, the Component Model automatically calls the resource’s destructor on the plugin side. The HashMap gets dropped. No memory leaks. No manual cleanup. It works like Rust’s Drop trait, but across the Wasm boundary.

Note: The exact method names on the host-side resource type depend on your wasmtime version. In wasmtime 29+, generated resource handles have call_<method> methods. If your version uses different names, check the generated bindgen output.

Why Resources?

You might wonder: why not pass the whole store’s contents back and forth as a list<config-entry> or a map<string, string>? Two reasons.

Some state can’t be serialized. A key-value store that round-trips its entire contents on every call isn’t a key-value store — it’s a serialization problem. Real systems have state that accumulates over time. A database connection. A running process. An open file. You can’t serialize these things. You need a handle.

The handle is opaque. The host can’t peek inside the kv-store handle. It can’t accidentally corrupt the plugin’s internal state. The Component Model enforces this boundary — resource handles are not pointers, not indices, not IDs you can forge. They’re typed, validated, and managed by the runtime. This is the same principle as capability-based security: the host can only do what the handle’s interface allows.

What About Integer IDs?

If you’re a systems programmer, you might be thinking: “why not have the plugin return an integer ID, store the HashMap in a side table, and pass the ID back for each call?” You can do this — it’s the C approach to handles. But it has three problems the Component Model solves:

  1. No type safety. An integer ID can be passed to the wrong function. delete_connection(handle_42) compiles fine when you meant delete_store(handle_42). A resource handle is typed — kv_store.call_delete() only compiles if the handle is a KvStore.
  2. No automatic cleanup across the boundary. RAII works on the host side, but the guest can’t detect a forgotten close. Resource handles coordinate cleanup automatically — host-side drop triggers guest-side destruction.
  3. No capability enforcement. Integer IDs can be forged. A malicious host could guess handle_99 and access state it shouldn’t. Resource handles are opaque — the host can only use the methods the WIT interface defines. It can’t peek, forge, or bypass.

The cleanup problem is worth spelling out. With integer IDs, the host can wrap the ID in a struct that calls close on Drop — that’s Rust’s RAII pattern, and it works fine on the host side. But that cleanup is one-sided. The guest (Wasm plugin) has no way to know the host dropped the handle.

Two failure modes follow from this asymmetry:

  • Host forgets to call close. The guest can’t detect the leak. Its side table grows without bound.
  • Guest drops its side of the state. The host’s stale ID now points to nothing — a use-after-free with no type-system protection.

When the host’s resource handle goes out of scope, the Component Model automatically calls the destructor on the guest side, and it ensures the guest can’t access a destroyed resource. The cleanup is coordinated across the boundary — on both sides, not only the host’s.

Integer IDs work. They’re what you’d use without the Component Model. Resources are the type-safe, memory-safe, capability-safe replacement.

Combining Results and Resources

Real plugin systems use both patterns together. A connect() function returns result<connection, connect-error>. A query() method on the connection returns result<rows, query-error>. The host handles errors at each step with the type system keeping it honest.

Here’s what that looks like in WIT:

record rows {
    values: list<list<string>>,
}

record query-error {
    message: string,
    code: u32,
}

record connect-error {
    message: string,
}

resource connection {
    query: func(sql: string) -> result<rows, query-error>;
    close: func();
}

export connect: func(url: string) -> result<connection, connect-error>;

A factory function (connect) creates the connection and returns result<connection, connect-error>. Methods on the connection also return result. The host handles errors at each step — the type system keeps it honest.

The Combined Plugin

The plugin combines both patterns: RefCell for interior mutability (from the resource section) and Result for error handling (from the result section). The ConnectionInner stores query results in a HashMap, and the connect factory function rejects bad URLs:

#![allow(unused)]
fn main() {
use std::cell::RefCell;
use std::collections::HashMap;

wit_bindgen::generate!({
    world: "example-plugin",
});

struct ConnectionInner {
    results: HashMap<String, Vec<Vec<String>>>,
}

pub struct ConnectionImpl {
    inner: RefCell<ConnectionInner>,
}

impl ConnectionImpl {
    fn new() -> Self {
        ConnectionImpl {
            inner: RefCell::new(ConnectionInner {
                results: HashMap::new(),
            }),
        }
    }
}

impl GuestConnection for ConnectionImpl {
    fn query(&self, sql: String) -> Result<Rows, QueryError> {
        self.inner
            .borrow()
            .results
            .get(&sql)
            .map(|rows| Rows { values: rows.clone() })
            .ok_or(QueryError {
                message: format!("no results for: {sql}"),
                code: 1,
            })
    }

    fn close(&self) {
        self.inner.borrow_mut().results.clear();
    }
}

struct ExamplePlugin;

impl Guest for ExamplePlugin {
    type Connection = ConnectionImpl;

    fn connect(url: String) -> Result<ConnectionImpl, ConnectError> {
        if url.starts_with("db://") {
            let conn = ConnectionImpl::new();
            conn.inner.borrow_mut().results.insert(
                "SELECT * FROM users".to_string(),
                vec![vec!["alice".to_string(), "42".to_string()]],
            );
            Ok(conn)
        } else {
            Err(ConnectError {
                message: format!("invalid URL: {url}"),
            })
        }
    }
}

export!(ExamplePlugin);
}

The structure should feel familiar — it’s the RefCell pattern from the KvStore combined with the Result pattern from parse. The new thing is how they interact: connect() returns Result<ConnectionImpl, ConnectError>, so the host can’t get a Connection handle without handling the error case first. And query() on that handle also returns Result, so every operation is type-safe.

The Combined Host

On the host side, you handle errors at each step — the type system forces it:

#![allow(unused)]
fn main() {
// connect can fail — handle the error
let conn = match plugin.call_connect(&mut store, "db://mydb")? {
    Ok(conn) => conn,
    Err(e) => {
        println!("Connection failed: {}", e.message);
        return Ok(());
    }
};

// query can also fail — handle the error
match conn.call_query(&mut store, "SELECT * FROM users")? {
    Ok(rows) => {
        for row in &rows.values {
            println!("row: {:?}", row);
        }
    }
    Err(e) => {
        println!("Query failed (code {}): {}", e.code, e.message);
    }
}

conn.call_close(&mut store)?;
}

Two error-handling sites, two match expressions. You can’t forget to handle the connect error because conn only exists in the Ok arm. You can’t forget to handle the query error because Result forces you to match. This is the Component Model working as designed: the WIT type system becomes the Rust type system on both sides of the boundary, and the compiler catches what you miss.

Note on resource handle calls: The host calls methods directly on the conn resource handle (conn.call_query, conn.call_close) — the same pattern as store_handle.call_get() from the KvStore example earlier. If your WIT uses interface-style packaging, the calls look different (storage.connection().call_query()), but the inline WIT pattern used in this tutorial gives you the simpler direct style.

Note: Resource constructors that return result directly (e.g., constructor(url: string) -> result<connection, connect-error>) are supported in wit-bindgen 0.41+ and wasmtime 29+. Earlier versions may not support this — the factory function pattern above works everywhere.

The combination is powerful: resources give you stateful handles, result types give you type-safe error handling, and the Component Model makes them work together without any manual plumbing.

What We Built

PartProblemComponent Model Solution
1Pass a string into WasmWIT function, type-safe bindings
2Pass structured dataWIT records, generated Rust structs
3Real plugin + host importsFull mdbook preprocessor, import in WIT
4Error handling + stateful handlesresult<T, E>, option<T>, resource handles

The Component Model gives you the same type-safety guarantees inside the Wasm boundary that you have in regular Rust code. Result forces you to handle errors. Option forces you to handle absence. Resources give you typed handles with automatic cleanup. You don’t have to invent a convention for any of these — the WIT type system defines them, and wit-bindgen implements them on both sides.

Going Further

Resources have more to them than we showed here. Three things worth knowing:

Custom destructors. When a resource handle is dropped on the host side, the Component Model calls the destructor on the guest side. By default, this drops the Rust struct. If you need cleanup — closing a file, flushing a buffer, releasing a lock — implement a drop method on the guest-side resource. It runs automatically when the host’s handle goes out of scope.

Borrowed resources. WIT supports passing resource handles as method arguments. This lets one resource reference another — a session that owns a connection, or a transaction that borrows a database. The Component Model tracks the borrowing: the guest resource can’t be destroyed while it’s borrowed by another call.

Async resources. The Component Model’s async proposal includes streams and futures as built-in resource types. A stream<string> would deliver values progressively without blocking. A future<result> would resolve asynchronously. These are resource types under the hood — the same handle semantics, the same automatic cleanup, but with async plumbing. As of May 2026, wasmtime’s Component Model async support is still in development — the Component Model spec defines these types, but you can’t yet use them with the wasmtime version in this tutorial. Check the Component Model spec and the wasmtime repo for updates.

Next: Part 5 — Composing Components — Two plugins, one pipeline: the formatter normalizes whitespace, then the word counter tallies the result. You could wire them through the host — call one, get the output, pass it to the next. But that’s verbose. With wac compose, the two plugins talk to each other directly inside the same Wasm module. The host never sees the intermediate data. It’s Unix pipes, but type-safe.