bytecodealliance / wasmtime

A fast and secure runtime for WebAssembly
https://wasmtime.dev/
Apache License 2.0
14.81k stars 1.24k forks source link

[Partially answered myself] [Question] Problems with leveraging `cargo component` and `wasmtime-wasi` to run a "plugin" on the host #8857

Open Froidoh opened 1 week ago

Froidoh commented 1 week ago

EDIT:

So it all boils down to:

how can I invoke a function on an interface?!

ORIGINAL:

Okay, so I am a total noob with wasm(time) and my mistake is totally a layer 8 problem. But after digging I couldn't find the solution, so here I am, opening an issue (sorry for that!)

What I want:

I want to leverage cargo component and wasmtime to create a simple plugin system experiment.

So I created a new (binary) webassembly component via: cargo component new test-plugin.

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

[dependencies]
wit-bindgen-rt = { version = "0.26.0", features = ["bitflags"] }

[profile.release]
codegen-units = 1
opt-level = "s"
debug = false
strip = true
lto = true

# [package.metadata.component]
# package = "component:test-plugin"

[package.metadata.component.target]
path = "wit"
world = "test-plugin"

[package.metadata.component.dependencies]
package justatest:testplugin@0.1.0;

interface greetings {
  hello: func(s: string) -> string;
}

world test-plugin {
  export greetings;
}

And my src/main.rs

#[allow(warnings)]
mod bindings;

use bindings::exports::justatest::testplugin::greetings::Guest;

struct Component;

impl Guest for Component {
    fn hello(s: String) -> String {
        format!("Hello {s}")
    }
}

bindings::export!(Component with_types_in bindings);

fn main() {
    println!("Hello from test-plugin!");
}

I then generated the binary via cargo component build --release and let it run via:

wasmtime target/wasm32-wasi/release/test-plugin.wasm Hello from test-plugin!

As expected. Via the wasmtime cli I cannot --invoke any functions on a wasm32-wasi webassembly module (at least that's what the cli tells me)...

So then I created a simple-host as a "normal", "native" rust project, compiled via llvm:

Cargo.toml:

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

[dependencies]
anyhow = "1.0.86"
wasmtime = "22.0.0"
wasmtime-wasi = "22.0.0"
#wit-component = "0.211.1"

main.rs

use wasmtime::{
    component::{Component, Linker, Val},
    Config, Engine,
};
use wasmtime_wasi::{WasiCtxBuilder, WasiView};

/*
fn convert_to_component(path: impl AsRef<Path>) -> wasmtime::Result<Vec<u8>> {

    let bytes = &std::fs::read(&path).context("failed to read input file")?;
    wit_component::ComponentEncoder::default()
        .module(&bytes)?
        .encode()
}
*/

struct Host {
    ctx: wasmtime_wasi::WasiCtx,
    table: wasmtime_wasi::ResourceTable,
}

impl WasiView for Host {
    fn table(&mut self) -> &mut wasmtime_wasi::ResourceTable {
        &mut self.table
    }

    fn ctx(&mut self) -> &mut wasmtime_wasi::WasiCtx {
        &mut self.ctx
    }
}

fn main() -> wasmtime::Result<()> {
    let mut config = Config::new();
    config.wasm_component_model(true);
    //config.async_support(true);
    let engine = Engine::new(&config)?;
    // Load the component from disk
    let component = std::fs::read("../test-plugin/target/wasm32-wasi/release/test-plugin.wasm")?;
    let component = Component::from_binary(&engine, &component)?;
    //println!("bytes: {bytes:?}");

    // As `cargo component` by default builds as `wasm32-wasi` webassembly module, we need to also include `wasmtime-wasi` and provide certain native functionality (in my own words)
    let mut wasi = WasiCtxBuilder::new();
    wasi.arg("--help");
    let host = Host {
        ctx: wasi.build(),
        table: wasmtime_wasi::ResourceTable::new(),
    };

    let mut store = wasmtime::Store::new(&engine, host);
    // Configure the linker
    let mut linker = Linker::new(&engine);
    wasmtime_wasi::add_to_linker_sync(&mut linker)?;
    // The component expects one import `name` that
    // takes no params and returns a string

    linker
            .root()
            //.func_wrap("name", |_store, _params: ()| Ok((String::from("Alice"),)))?
        ;

    println!("have linker");
    // Instantiate the component
    let instance = linker.instantiate(&mut store, &component)?;
    // TODO: find out whethere there is a way to programmatically "explore" an instance,
    // to see it's "API" so to speak.
    // Could be useful for generating some docs or whatnot
    /*
    let mut exports = instance.exports(&mut store);
    let root = exports.instance("greetings").expect("greetings to exist");
    let modules = root.modules();
    for (name, module) in modules {
        println!("module: {module:?}")
    }
    println!("no modules?!");
    */

    println!("have instance");

    /*
    // TODO: I definitely misunderstood modules, because "greetings" does not exist
    let module = instance
        .get_module(&mut store, "greetings")
        .expect("module not found");
    */

    // Call the `greet` function
    let func = instance
        .get_func(&mut store, "hello")
        .expect("hello export not found");
    let mut result = [wasmtime::component::Val::String("".into())];
    func.call(
        &mut store,
        &[Val::String("freund nachtigall".to_string())],
        &mut result,
    )?;

    println!("Greeting: {:?}", result);

    Ok(())
}

When I run this I get a panic for hello export not found.

What am I doing wrong? I also tried with: greetings.hello and greetings:hello for get_func but to no avail.

On the other hand, if I create a library component via cargo component new test-plugin-lib --lib which creates a default wit file and impl for it:

package component:test-plugin-lib;

/// An example world for the component to target.
world example {
    export hello-world: func() -> string;
}

and then load this and call it like so:

    let func = instance
        .get_func(&mut store, "hello-world")
        .expect("hello export not found");
    let mut result = [wasmtime::component::Val::String("".into())];
    func.call(
        &mut store,
        &[],
        //&[Val::String("freund nachtigall".to_string())],
        &mut result,
    )?;

    println!("Greeting: {:?}", result);

it works like a charm.

So I guess my problem boils down to one or two things:

1) How do I get a function in an interface programmatically like hello if the wit file looks like:

package justatest:testplugin@0.1.0;

interface greetings {
  hello: func(s: string) -> string;
}

world test-plugin {
  export greetings;
}

~2) Maybe it's different when it's a "binary" webassembly component in contrast to a lib?~

I can answer that one: It's not!

If I add a helloto function like to:

package justatest:testplugin@0.1.0;

interface greetings {
  hello: func(s: string) -> string;
}

world test-plugin {
  export greetings;
  export helloto: func(name: string) -> string;
}

and implement it like so:

#[allow(warnings)]
mod bindings;

use bindings::exports::justatest::testplugin::greetings::Guest;
use bindings::Guest as WorldGuest;

struct Component;

impl Guest for Component {
    fn hello(s: String) -> String {
        format!("Hello {s}")
    }
}

impl WorldGuest for Component {
    fn helloto(name: String) -> String {
        format!("Bonjour {name}")
    }
}

bindings::export!(Component with_types_in bindings);

fn main() {
    println!("Hello from test-plugin!");
}

then I can invoke helloto just fine :)

So it all boils down to:

how can I invoke a function on an interface?!

pchickey commented 1 week ago

Hi! You did a lot of figuring things out in your journey to this point, in a brand new technology with docs we know could use some work, so great job so far. It looks like the bit missing here is that when hello is in an interface called greetings, so first you must get the greetings instance out of your root instance: instance.exports(&mut store).instance("greetings").expect("root has greetings instance").typed_func("hello").

An alternative is to use wasmtime::component::bindgen! in your host, which will generate, among other things, a TestPlugin struct, which has typed, string-name-free methods to access all of the world's exports. You can use cargo doc --document-private-items to see the extent of what that macro generates, or if WASMTIME_DEBUG_BINDGEN is set during build, the generated code will be in OUT_DIR. That generated code should be able to replace everything in your host from println!(have linker) down.

The other reason to recommend bindgen is that wasmtime 23 will change the way instance exports work in order to make reuse more efficient, so the answer in the first paragraph will need to be rewritten (https://docs.wasmtime.dev/api/wasmtime/component/struct.Instance.html, you'll need to call, pardon shorthand, get_typed_func(get_export(get_export("greetings"), "hello")))

One final suggestion - its fine to file issues like these, and also we invite this sort of question / debugging journey on https://bytecodealliance.zulipchat.com/.

Froidoh commented 1 week ago

@pchickey thanks for taking the time to answer!

The problem with the macro approach is that I want to write a dynamic plugin system, so I don't know the wasm components aka plugins that are available at runtime.

I would definitely prefer the "non stringified function calls" but, correct me if wrong, that's currently not possible then.

What I would need to get this dynamically at runtike working is something like a "interpret this component as an instance of this (rust) trait" or a "cast" so to speak

pchickey commented 1 week ago

The bindgen! happens at rustc compile time, yes, so you have to know the world required to run your component at that time.

If you have some known set of rust traits that are your cast targets, then you might be able to generate code for the set of worlds that correspond to those targets. You could use bindgen! for that, but you don't have to - bindgen is just a convenience on top of the public wasmtime API, so you can get the same type safety by just using Instance exports and typed_func: wasmtime will check at runtime to make sure the component you load corresponds to those types. So, there are many ways to go about solving this depending on your use case.