Just a bit of wasi
July 2022
Background
I’ve been working and helping with cargo-binstall a lot recently. We’re currently discussing how to support WASI applications.
As part of this, one idea for detecting if the host (your computer) supports running WASI programs was to make a small WASI program, embed it in cargo-binstall, and then we can try running it: if it works, WASI runs on your machine, if there are any errors, we’ll call that a no.
(This means that so far the only way to pass the test is to have an OS which can run WASI programs directly. On Linux, you can configure that! using binfmt_misc. The hope is that other OSes will eventually get native WASI support, or something like that Linux capability. We discussed having cargo-binstall handle making a wrapper that calls a WASI runtime, but haven’t yet decided if that’s something we want to do. This is very fresh! We’re in the middle of it. Join the discussion on the ticket if you have ideas/desires/commentary! 😸)
So: we need to make a small WASI program. Small enough that we can embed it without suffering. Cargo-binstall has recently had a big push towards both fast compile times and small binary size, and we don’t want to undo that.
Hello, World!
First idea: start from a Rust WASI Hello World and optimise from there.
That tutorial goes to install cargo-wasi, but I thought, hmm, I think we can do simpler.
$ cargo new hello-wasi
$ cd hello-wasi
$ echo "fn main() {}" > src/main.rs
$ rustup target add wasm32-wasi
$ cargo build --target wasm32-wasi --release
That’s it! No cargo plugin needed. Let’s see how small that got…
$ exa -l target/wasm32-wasi/release/hello-wasi.wasm
.rwxr-xr-x@ 2.0M passcod 26 Jul 19:53 target/wasm32-wasi/release/hello-wasi.wasm
Gah! Two whole megabytes?!
We could probably optimise this, but it seems like it would be easier to…
Start from a smaller base
How about we get rid of the standard library? That would help, right? Let’s google for smallest no_std program rust…
#![no_std]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_panic: &PanicInfo<'_>) -> ! {
loop {}
}
Ok, a program that does nothing, and does nothing on panic, which it won’t because it does nothing. I’m sure this will be smaller. Right?
$ cargo build --target wasm32-wasi --release
$ exa -l target/wasm32-wasi/release/hello-wasi.wasm
.rwxr-xr-x@ 281 passcod 26 Jul 20:01 target/wasm32-wasi/release/hello-wasi.wasm
281 BYTES. Ok, now we’re cooking.
So, what does it do when run?
$ pacman -S wasmtime
$ wasmtime target/wasm32-wasi/release/hello-wasi.wasm
Error: failed to run main module `target/wasm32-wasi/release/hello-wasi.wasm`
Caused by:
0: failed to instantiate "target/wasm32-wasi/release/hello-wasi.wasm"
1: unknown import: `env::__original_main` has not been defined
Uhhhhh. That’s not good.
Maybe we do need a main
That program above doesn’t have a fn main()
. Maybe that’s what it’s complaining about? Let’s see:
#![no_std]
#![feature(start)]
use core::panic::PanicInfo;
#[panic_handler]
fn panic(_panic: &PanicInfo<'_>) -> ! {
loop {}
}
#[start]
fn main(_argc: isize, _argv: *const *const u8) -> isize {
0
}
That took a bit to figure out, but we got there:
$ cargo build --target wasm32-wasi --release
Compiling hello-wasi v0.1.0 (/home/code/rust/hello-wasi)
error[E0554]: `#![feature]` may not be used on the stable release channel
--> src/main.rs:2:1
|
2 | #![feature(start)]
| ^^^^^^^^^^^^^^^^^^
For more information about this error, try `rustc --explain E0554`.
error: could not compile `hello-wasi` due to previous error
Ah, right:
$ cargo +nightly build --target wasm32-wasi --release
Compiling hello-wasi v0.1.0 (/home/code/rust/hello-wasi)
error[E0463]: can't find crate for `core`
|
= note: the `wasm32-wasi` target may not be installed
= help: consider downloading the target with `rustup target add wasm32-wasi`
= help: consider building the standard library from source with `cargo build -Zbuild-std`
error[E0463]: can't find crate for `compiler_builtins`
error[E0463]: can't find crate for `core`
--> src/main.rs:4:5
|
4 | use core::panic::PanicInfo;
| ^^^^ can't find crate
|
= note: the `wasm32-wasi` target may not be installed
= help: consider downloading the target with `rustup target add wasm32-wasi`
= help: consider building the standard library from source with `cargo build -Zbuild-std`
error: requires `sized` lang_item
For more information about this error, try `rustc --explain E0463`.
error: could not compile `hello-wasi` due to 4 previous errors
What now? …oh, the target I added earlier was for stable, not for nightly.
$ rustup target add --toolchain nightly wasm32-wasi
$ cargo +nightly build --target wasm32-wasi --release
$ wasmtime target/wasm32-wasi/release/hello-wasi.wasm
Error: failed to run main module `target/wasm32-wasi/release/hello-wasi.wasm`
Caused by:
0: failed to instantiate "target/wasm32-wasi/release/hello-wasi.wasm"
1: unknown import: `env::exit` has not been defined
Well, at least that’s a different error I guess.
Looking back
Wait, we never tested that first program. Does it work?
$ cp src/main.rs src/not-working.rs
$ echo "fn main() {}" > src/main.rs
$ cargo build --target wasm32-wasi --release
$ wasmtime target/wasm32-wasi/release/hello-wasi.wasm
$ echo $?
0
Yep. Sure does.
Opening it up
How about we disassemble the WASM into WAST (like “assembly” for other targets) and have a look, maybe we can grep for
these env::exit
and env::__original_main
to see what they’re defined to in the WASI that works, and if they’re there
in the WASI that doesn’t.
$ pacman -S binaryen
$ wasm-dis target/wasm32-wasi/release/hello-wasi.wasm -o wasi-goes.wast
$ cp src/not-working.rs src/main.rs
$ cargo build --target wasm32-wasi --release
$ wasm-dis target/wasm32-wasi/release/hello-wasi.wasm -o wasi-nope.wast
$ rg __original_main wasi-goes.wast -C3
42- (br_if $label$1
43- (i32.eqz
44- (local.tee $0
45: (call $__original_main)
46- )
47- )
48- )
--
984- )
985- (return)
986- )
987: (func $__original_main (result i32)
988- (local $0 i32)
989- (local $1 i32)
990- (local $2 i32)
--
1008- (func $main (param $0 i32) (param $1 i32) (result i32)
1009- (local $2 i32)
1010- (local.set $2
1011: (call $__original_main)
1012- )
1013- (return
1014- (local.get $2)
Well, it’s definitely in there in the working WASI. Let’s look at the not working one:
$ rg __original_main wasi-nope.wast -C3
20- (br_if $label$1
21- (i32.eqz
22- (local.tee $0
23: (call $__original_main)
24- )
25- )
26- )
--
30- (unreachable)
31- )
32- )
33: (func $__original_main (result i32)
34- (i32.const 0)
35- )
36- (func $main (param $0 i32) (param $1 i32) (result i32)
37: (call $__original_main)
38- )
39- ;; custom section "producers", size 28
40-)
It’s definitely in there too. I guess that makes sense, given the first error went away. What about exit
?
$ rg exit wasi-goes.wast
22: (import "wasi_snapshot_preview1" "proc_exit" (func $__imported_wasi_snapshot_preview1_proc_exit (param i32)))
49: (call $exit
24306: (call $__wasi_proc_exit
24573: (func $__wasi_proc_exit (param $0 i32)
24574: (call $__imported_wasi_snapshot_preview1_proc_exit
24585: (func $exit (param $0 i32)
$ rg exit wasi-nope.wast
6: (import "env" "exit" (func $exit (param i32)))
27: (call $exit
Uhh, well, it’s different, but it does exist on both sides.
Or does it?
Actually, with a bit of squinting, the wasmtime error makes sense! In that wasi-nope.wast
program, we “import”
env::exit
. And what wasmtime is saying it “nope, I don’t have that available.” But in the wasi-goes.wast
program,
we import something else:
(import "wasi_snapshot_preview1" "proc_exit"
(func $__imported_wasi_snapshot_preview1_proc_exit
(param i32)))
That looks like it’s part of the WASI API, and wasmtime has no issue providing it.
Do we even need them?
Before we get too far, since we have a small program that fits in one screenful in WAST, and we can assemble WAST into WASM, we can do some quick and dirty experimentation.
(module
(type $none_=>_i32 (func (result i32)))
(type $i32_=>_none (func (param i32)))
(type $none_=>_none (func))
(import "env" "__original_main" (func $fimport$0 (result i32)))
(import "env" "exit" (func $fimport$1 (param i32)))
(global $global$0 i32 (i32.const 1048576))
(global $global$1 i32 (i32.const 1048576))
(memory $0 16)
(export "memory" (memory $0))
(export "_start" (func $0))
(export "__data_end" (global $global$0))
(export "__heap_base" (global $global$1))
(func $0
(local $0 i32)
(if
(local.tee $0
(call $fimport$0)
)
(block
(call $fimport$1
(local.get $0)
)
(unreachable)
)
)
)
;; custom section "producers", size 28
)
Let’s start by removing the __original_main
and exit
imports, and removing mention of these imports ($fimport$0
and $fimport$1
) from what looks like the main function, func $0
, at the bottom there:
(module
(type $none_=>_i32 (func (result i32)))
(type $i32_=>_none (func (param i32)))
(type $none_=>_none (func))
(global $global$0 i32 (i32.const 1048576))
(global $global$1 i32 (i32.const 1048576))
(memory $0 16)
(export "memory" (memory $0))
(export "_start" (func $0))
(export "__data_end" (global $global$0))
(export "__heap_base" (global $global$1))
(func $0(local $0 i32))
)
Does that work?
$ wasm-as hello-wasi.wast -o wasi-reborn.wasm
$ wasmtime wasi-reborn.wasm
$ echo $?
0
Whoop! yes it does.
And can we trim that even more?
(module
(global $global$0 i32 (i32.const 0))
(global $global$1 i32 (i32.const 0))
(memory $0 16)
(export "memory" (memory $0))
(export "_start" (func $0))
(export "__data_end" (global $global$0))
(export "__heap_base" (global $global$1))
(func $0 (local $0 i32))
)
We sure can. I got there by carefully removing and tweaking things one at a time, but that’s what I ended up with. So. How big is that?
$ exa -l wasi-reborn.wasm
.rw-r--r--@ 93 passcod 26 Jul 20:36 wasi-reborn.wasm
93 bytes. Well, we can call that done, and
Is it really WASI though?
Uhh. Well, I guess not? It’s “just” WASM stuff, no WASI API. So we’d really be testing for a WASM runtime, not WASI.
Hey, we have that exit function import above, there, can we use that?
(module
(import "wasi_snapshot_preview1" "proc_exit" (func $exit (param i32)))
(global $global$0 i32 (i32.const 0))
(global $global$1 i32 (i32.const 0))
(memory $0 16)
(export "memory" (memory $0))
(export "_start" (func $0))
(export "__data_end" (global $global$0))
(export "__heap_base" (global $global$1))
(func $0
(call $exit (i32.const 0))
(unreachable)
)
)
$ wasm-as hello-wasi.wast -o wasi-revolution.wasm
$ wasmtime wasi-revolution.wasm
$ echo $?
0
$ exa -l wasi-revolution.wasm
.rw-r--r--@ 137 passcod 26 Jul 20:43 wasi-revolution.wasm
Okay! 137 bytes, and we’re calling a WASI import. Sounds good to me.
Extra credit
Do we even need the __data_end
, __heap_base
, and memory
? Let’s try without:
(module
(import "wasi_snapshot_preview1" "proc_exit" (func $exit (param i32)))
(export "_start" (func $0))
(func $0
(call $exit (i32.const 0))
(unreachable)
)
)
$ wasm-as hello-wasi.wast -o wasi-reprisal.wasm
$ wasmtime wasi-reprisal.wasm
Error: failed to run main module `wasi-reborn.wasm`
Caused by:
0: failed to invoke command default
1: missing required memory export
wasm backtrace:
0: 0x4f - <unknown>!<wasm function 1>
That’s a nope on the memory, so let’s restore that:
#![allow(unused)] fn main() { (module (import "wasi_snapshot_preview1" "proc_exit" (func $exit (param i32))) (memory $0 16) (export "memory" (memory $0)) (export "_start" (func $0)) (func $0 (call $exit (i32.const 0)) (unreachable) ) ) }
$ wasm-as hello-wasi.wast -o wasi-renewal.wasm
$ wasmtime wasi-renewal.wasm
$ echo $?
0
$ exa -l wasi-renewal.wasm
.rw-r--r--@ 97 passcod 26 Jul 20:43 wasi-renewal.wasm
Full points for me!