DEV Community

ndesmic
ndesmic

Posted on

Building a minimal WASI polyfill for browsers

While researching some WASM/WASI stuff I was trying to get my app to run in a variety of environments and with various programming language bindings. While most popular languages have bindings through Wasmtime and support WASI out of the box I was completely shocked that there doesn't seem to be a standard working polyfill for browsers.

If you search "WASI polyfill" on Google the primary result is this page: https://wasi.dev/polyfill/ with nothing on it. Apparently the polyfill was removed and there's no useful information here. If you peek back into the commit history on Github there was a polyfill that seems to forward requests to another WASM app, weird.

The page also suggests https://github.com/bjorn3/browser_wasi_shim which I tried and couldn't get to work and it comes with no real documentation. Lastly was wasmer-js which I had a number of issues with the module output when used from unpkg and skypack, and it also didn't work when I ran it.

So, I guess it's time to roll up the sleeves and understand what's actually going on under the hood so we can make a polyfill. This actually turns out to be somewhat hard as well. Despite being a spec, there is not much authoritative documentation on the actual signatures WASI functions use. You can find that a function takes 4 parameters as i32s, but what those mean is anyone's guess. Hopefully this can serve as an extra resource to help you along.

Note that I'm only implementing a few basic calls, there's a lot of them and many are for specific things I don't need. Specifically we'll look at:

  • fd_write (file and stdout)
  • fd_read (file and stdin)
  • environ_get (get environment variables)
  • environ_sizes_get (gets the size of environment variables)

WASI basics

This topic is a little more intermediate and getting into the details of WASM is out of scope for this post. WASM modules in their vanilla state cannot do any sort of IO. This helps keep them secure and helps keep the WASM spec small. In order to do IO things like write to a standard output the host environment has to pass in functions for the WASM module to use. The problem here is that you need to create some "glue code." Each language has a different convention, how are strings encoded? What order are the params etc. Implementers who want to use your module need to know how to make it work, meaning they get to write that glue code and WASM's pointers and memory paradigm is quite complicated making this brittle and unfun. Now imagine doing that for every module in every platform you wanted to run it on.

WASI standardizes some functions so that they take the same signature and behave the same way. This means that as long as you provide WASI interfaces, a WASI compatible WASM module can do normal IO. This was largely modeled after POSIX and many of those paradigms persist, after all WASM was designed to port applications that have already been written. WASI is not the same as POSIX though, certain things just didn't make sense in the contexts where WASM runs.

A basic app

Writing a WASM app is a little involved. You can use a number of languages but you do need to know a bit about their compiler toolchains. You can also hand-craft .wat, a LISP-looking text format that's pretty much 1:1 with the binary. I found this even harder not just because of the low level language but because WBAT which is where the canonical WAT to WASM compiler is apparently doesn't run on windows. There is a WASM port via WAPM but since WAT is a big topic to itself let's set it aside.

Instead I chose Rust which is perhaps the most popular and most battle tested way to write WASM today. Install rust and then run cargo install wasm to add the wasm toolchain. Create a new binary with cargo new --bin {name}. This will generate a new folder with src/main.rs. All you need to get started is:

pub fn main() {
    println!("Hello World");
}
Enter fullscreen mode Exit fullscreen mode

This does exactly what you think, but it specifically writes to standard out which means to compile to WASM we need WASI. You can compile it with cargo wasi build --release. The --release flag makes things slightly smaller but due to exception handling, strings and whatnot the WASM file will likely be a lot larger than you might expect for such a small program. Much more than a minimal .wat example you might find.

The .wasm file gets output into the target/wasm32-wasi/release folder.

Running the WASM code in the browser

So we have a .wasm file, we run this in the browser.

<!-- index.html -->
<html>
    <body>
        <script src="app.js" type="module"></script>
    </body>
</html>
Enter fullscreen mode Exit fullscreen mode
//app.js
const { instance } = await WebAssembly.instantiateStreaming(fetch("../target/wasm32-wasi/release/main.wasm"), imports);
instance.exports._start();
Enter fullscreen mode Exit fullscreen mode

This is how you can run it ... almost. First off, there are about half a dozen ways to instantiate a WASM module you'll come across, the above instantiateStreaming is the best way (at least until we can directly import .wasm modules) so if it looks different than some other tutorial that's why. instantiateStreaming takes two things a fetch promise and an object with our "imports". The imports are the functions we pass to the WASM module so it can call out to our code. This is where the WASI goes.

Also, WASI programs use the paradigm of the entrypoint being _start so we call that on the modules exports.

If you run this you'll get some errors like Uncaught LinkError: WebAssembly.instantiate(): Import #2 module="wasi_snapshot_preview1" function="environ_get" error: function import requires a callable. This means that it couldn't find that function in the imports. We can tell it's WASI because at the time of writing wasi_snapshot_preview1 is the module name used by WASI and at least in this case it's missing function environ_get.

Let's fix these errors. We can start by stubbing it all out.

const imports = { 
    "wasi_snapshot_preview1": {
        environ_sizes_get(){ return 0; },
        environ_get() { return 0; },
        proc_exit() { return 0; },
        fd_write() { return 0; },
    }
}
Enter fullscreen mode Exit fullscreen mode

This should stub out all the used functions. You can fix the errors one-by-one by adding stubs like these. While I found it's not strictly required here, returning 0 on success is proper.

This won't run though, you'll get an unreachable error. This is an exception that comes from the code rust generated because we didn't actually do what was expected of us when it made those calls. Specifically, the error happens on fd_write (if you are brave enough to debug through the WASM file in the debugger you'll find this call).

Printing to stdout

The signature of fd_write is function fd_write(fd: int32, iovs: int32, iovsLength: int32, bytesWrittenPtr: int32) using the general argument naming conventions I found. How this works is not obvious at all.

Firstly we have fd which is the "File Descriptor", basically a numeric id to a file on a file system. In the POSIX world standard in, standard out and standard error are considered files that can be written to and they have ids 0, 1 and 2 respectively. So any WASI app wanting to print something to standard IO will use this convention. Therefore for value 1 we can simply console.log the data (or in a UI dump it out to a DOM element).

The second argument is a cryptic iovs. This I think stands for "in out vectors". This value is a pointer to a list of 32-bit pointer/length values, eg each in out vector is 8 bytes in total. The 3rd partmeter iovsLength tell you how many of these structures to read. I'm not actually sure why they do it like this. Perhaps because it may not fit in one chunk of memory?

In our "hello world" case there seems to be exactly 1 in out vector. The first 4 bytes are the starting address of "hello world" and we read 12 bytes total.

The last parameter is a pointer to "bytes written". The function expects you to write how many bytes you wrote to this location. I think this is for streaming data, like in the case you can't write as many as it asks for it can try again with the remaining data.

The final function I came up with:

fd_write(fd, iovsPtr, iovsLength, bytesWrittenPtr){
    const iovs = new Uint32Array(instance.exports.memory.buffer, iovsPtr, iovsLength * 2);
    if(fd === 1) { //stdout
        let text = "";
        let totalBytesWritten = 0;
        const decoder = new TextDecoder();
        for(let i =0; i < iovsLength * 2; i += 2){
            const offset = iovs[i];
            const length = iovs[i+1];
            const textChunk = decoder.decode(new Int8Array(instance.exports.memory.buffer, offset, length));
            text += textChunk;
            totalBytesWritten += length;
        }
        const dataView = new DataView(instance.exports.memory.buffer);
        dataView.setInt32(bytesWrittenPtr, totalBytesWritten, true);
        console.log(text);
    }
    return 0;
},
Enter fullscreen mode Exit fullscreen mode

This will cause standard out to be forwarded to console.log but will ignore all file writes including standard error. Still with the signature decoded it's much easier to see how to implement those.

If all goes well you should see "Hello World" in the console and no other errors.

Reading from stdin

Let's update main.rs and recompile:

use std::io;

pub fn main() -> io::Result<()> {
    let mut buffer = String::new();
    io::stdin().read_line(&mut buffer)?;
    println!("Hello {}", buffer);
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

We need to import std::io to read from standard in and we place it in a string buffer. The extra syntax for mutable variables and why the signature changed to io::Result<()> is a little beyond the scope of this post but this is the most minimal way to read from standard in.

Reading from stdin works the opposite direction, we need to implement fd_read. The signature looks like function fd_read(fd: int32, iovs: int32, iovsLength: int32, bytesReadPtr: int32). It's the same as fd_write except the operations will be flipped. We write to iovs.

fd_read(fd, iovsPtr, iovsLength, bytesReadPtr){
    const memory = new Uint8Array(instance.exports.memory.buffer);
    const iovs = new Uint32Array(instance.exports.memory.buffer, iovsPtr, iovsLength * 2);
    let totalBytesRead = 0;
    if(fd === 0) {//stdin
        for(let i = 0; i < iovsLength * 2; i += 2){
            const offset = iovs[i];
            const length = iovs[i+1];
            const chunk = stdin.slice(0, length);
            stdin = stdin.slice(length);
            memory.set(chunk, offset);
            totalBytesRead += chunk.byteLength;
            if(stdin.length === 0) break;
        }
        const dataView = new DataView(instance.exports.memory.buffer);
        dataView.setInt32(bytesReadPtr, totalBytesRead, true);
    }
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

If fd is 0 then we're reading from standard in. We're going to write what we have at those pointers. This is actually a bit annoying since we need to keep track of how much we wrote. Basically we fill up the buffer and when it's full we move onto the next one. We loop over iovs pointers (remember they are 2 32-bit values hence why we iterate up to length * 2). Then we slice from 0 to the total length of the buffer from the stdin value (stdin is a UInt8Array). If the buffer was larger than the remaining stdin it will just slice to end. Then we can set that value to the offset given in the iov. memory looks at the buffer as an array of UInt8s so that we can write a UInt8Array to the pointer offset. We update the bytes read and continue only if there's more in the stdin buffer. Finally we write the totalBytesRead.

I had hoped to implement this as a stream since that essentially what standard in is but it doesn't seem like we can do that. When you read from a stream the read is async and fd_read is sync so I had to leave it as a UInt8Array. It looks like this:

const textEncoder = new TextEncoder();
let stdin = textEncoder.encode("stdin");
Enter fullscreen mode Exit fullscreen mode

This unfortunately makes things very awkward, at least from a javascript app perspective since no there's no standard in on a web browser. Maybe we can clean this up if we built a terminal component.

If everything goes well you should see Hello stdin in the console.

Reading environment variables

We can update the rust code again to use environment variables:

use std::io;
use std::env;

pub fn main() -> io::Result<()> {
    let mut buffer = String::new();
    io::stdin().read_line(&mut buffer)?;

    let foo_var = env::var("FOO").unwrap_or("[foo]".to_string());

    println!("Hello {} {}", buffer, foo_var);
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

The unwrap_or stuff is how rust handles errors if you don't speak it. We default the value to [foo] if it's not found which is helpful for debugging.

Reading the environment variables is two parts. I don't really understand why it's two but that's how it is. environ_get is the main one, the one that actually gets the environment data. environ_sizes_get gets the length of the buffer that the environment data was written to. We'll start with that one because it's easier.

environ_sizes_get has signature function environ_sizes_get(environCountPtr, environBufferSizePtr). All we need to due to write the number of environment variables and the total length of their data to those pointers. The first part is easy, we just count the environment variables and write that 32-bit value to the assigned place in memory.

The actual data is cryptic. It turns out the way to encode the environment data is a set of strings with the format KEY=VALUE followed by a null byte \0 So, we can map the strings, add \0 and then run them through the TextEncoder (at least I think so, this worked but I saw another implementation that joined the strings with \0 meaning there's one less byte as the final value has no trailing \0). The number of bytes produced is the size.

So we can expand on the above and create an intermediate format that's a list of strings encoded as UInt8Arrays

const env = {
    FOO: "FOO",
    BAR: "BAR"
};

const envStrings = Object.entries(env).map(([k, v]) => `${k}=${v}`);
const envEncodedStrings = envStrings.map(s => textEncoder.encode(s + "\0"))
Enter fullscreen mode Exit fullscreen mode

So to calculate the sizes, I got this:

environ_sizes_get(environCountPtr, environBufferSizePtr){
    const envByteLength = envEncodedStrings.map(s => s.byteLength).reduce((sum, val) => sum + val);
    const countPointerBuffer = new Uint32Array(instance.exports.memory.buffer, environCountPtr, 1);
    const sizePointerBuffer = new Uint32Array(instance.exports.memory.buffer, environBufferSizePtr, 1);
    countPointerBuffer[0] = envEncodedStrings.length;
    sizePointerBuffer[0] = envByteLength;
    return 0;
},
Enter fullscreen mode Exit fullscreen mode

I chose to write into buffers of length 1 here, but you could also use a DataView instead.

The count and size must match the implementation in environ_get or bad things could happen. environ_get has signature function environ_get(environPtr, environBufferPtr). The first pointer points to a location with a list of offsets. That is, if you had 3 environment variables you'd write 3 32-bit values. The environBufferPtr is where the actual string data we computed above is stored and the pointers we calculated point to the beginning of each string.

environ_get(environPtr, environBufferPtr){
    const envByteLength = envEncodedStrings.map(s => s.byteLength).reduce((sum, val) => sum + val);
    const environsPointerBuffer = new Uint32Array(instance.exports.memory.buffer, environPtr, envEncodedStrings.length);
    const environsBuffer = new Uint8Array(instance.exports.memory.buffer, environBufferPtr, envByteLength)

    let pointerOffset = 0;
    for(let i = 0; i < envEncodedStrings.length; i++){
        const currentPointer = environBufferPtr + pointerOffset;
        environsPointerBuffer[i] = currentPointer;
        environsBuffer.set(envEncodedStrings[i], pointerOffset)  
        pointerOffset += envEncodedStrings[i].byteLength;
    }
    return 0;
},
Enter fullscreen mode Exit fullscreen mode

I use the same technique of creating a buffer out of TypedArray views but this time it makes a little more sense because there is more than one element. A word of caution as I got tripped up here. The signature UInt32Array(buffer, offset, length) can be confusing even if you read the MDN page. The offset is in bytes, that is, it doesn't have to be a multiple of 4. The length is the number of elements not the length in bytes.

Any way in the console hopefully you get "Hello stdin FOO".

proc_exit

As far as I can tell this is just a callback that the program exited. It was not actually called when I ran the module so I'm not sure what distinguishes it but it's still required to have because the WASM code references it. You can just make the function console.log a message.

With this you should have enough to start implementing patterns like WAGI which can use the existing WASI primitives to write language agnostic request handlers.

Bonus args_get and args_sizes_get

Since these are pretty much identical to environs I thought I'd throw them in there since it's very common to need these for CLI apps.

args_sizes_get(argCountPtr, argBufferSizePtr) {
    const argByteLength = this.#argEncodedStrings.reduce((sum, val) => sum + val.byteLength, 0);
    const countPointerBuffer = new Uint32Array(this.#instance.exports.memory.buffer, argCountPtr, 1);
    const sizePointerBuffer = new Uint32Array(this.#instance.exports.memory.buffer, argBufferSizePtr, 1);
    countPointerBuffer[0] = this.#argEncodedStrings.length;
    sizePointerBuffer[0] = argByteLength;
    return 0;
}
args_get(argsPtr, argBufferPtr) {
    const argsByteLength = this.#argEncodedStrings.reduce((sum, val) => sum + val.byteLength, 0);
    const argsPointerBuffer = new Uint32Array(this.#instance.exports.memory.buffer, argsPtr, this.#argEncodedStrings.length);
    const argsBuffer = new Uint8Array(this.#instance.exports.memory.buffer, argBufferPtr, argsByteLength)
    let pointerOffset = 0;
    for (let i = 0; i < this.#argEncodedStrings.length; i++) {
        const currentPointer = argBufferPtr + pointerOffset;
        argsPointerBuffer[i] = currentPointer;
        argsBuffer.set(this.#argEncodedStrings[i], pointerOffset)
        pointerOffset += this.#argEncodedStrings[i].byteLength;
    }
    return 0;
}
Enter fullscreen mode Exit fullscreen mode

You can modify main.rs to try them out:

use std::io;
use std::env;

pub fn main() -> io::Result<()> {
    let mut buffer = String::new();
    io::stdin().read_line(&mut buffer)?;

    let foo_var = env::var("FOO").unwrap_or("[foo]".to_string());

    let args: Vec<String> = env::args().collect();

    println!("Hello World! stdin: {}, env: {}, args: {}", buffer, foo_var, &args[0]);
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

Incorporating into a library

Unfortunately the flow for WASM instantiation kinda sucks. WASI implementations will need access to the instance's memory which creates a nasty circular loop where the instance depends on the WASI implementation because it's required to create one but also that the WASI implementation needs the instance to operate on it. In order to make a nice class I had to add an additional required setter to supply the instance reference after you have it.

const wasi = new Wasi({
    stdin: "stdio",
    env: {
        FOO: "FOO",
        BAR: "BAR"
    },
    args: ["--my-arg"]
});

const { instance } = await WebAssembly.instantiateStreaming(fetch("../target/wasm32-wasi/release/main.wasm"), {
    "wasi_snapshot_preview1": wasi
});
wasi.instance = instance;
Enter fullscreen mode Exit fullscreen mode

I also wanted to have the class use an exports object rather than be the import object directly. This means you can't use private fields though because the exported object cannot use references to them. Furthermore, when the WASM module calls out to the functions the this argument is lost so you need to bind them all. Really just annoying all around. I think this is why the Deno implementation has a method to start the instance. It then has a proper instance it can hold a reference to before invoking the _start method. It's just unfortunate it has to be this way.

Code

You can find the WASI polyfill code here: https://github.com/ndesmic/wasm-cross/blob/v0/browser/wasi.js

Reference

Oldest comments (5)

Collapse
 
konsumer profile image
David Konsumer

Hey, I like what you did here and great write-up, but the github is a dead link. I am working on a similar, but more complete browser WASI fs setup. I can't make heads-or-tails of how to connect to a virtual FS (I was using BrowserFS.) I'd prefer to not have to preload all the files, and just random-access grab them from a zip file. Did you ever do any more with this?

Collapse
 
konsumer profile image
David Konsumer • Edited

I have everything working except fd_read, whcih seems to get in a loop:

    fd_read (fd, iovsPtr, iovsLength, bytesReadPtr) {
      if (fd === 1 || fd === 2) {
        throw new Error('Cannot read from stdout/stderr')
      }

      const memory = new Uint8Array(instance.exports.memory.buffer)
      const iovs = new Uint32Array(instance.exports.memory.buffer, iovsPtr, iovsLength * 2)
      let totalBytesRead = 0

      if (fd === 0) { // stdin
        for (let i = 0; i < iovsLength * 2; i += 2) {
          const offset = iovs[i]
          const length = iovs[i + 1]
          const chunk = wasi._stdin.slice(0, length)
          wasi._stdin = wasi._stdin.slice(length)
          memory.set(chunk, offset)
          totalBytesRead += chunk.byteLength
          if (wasi._stdin.length === 0) break
        }
      } else {
        // TODO: move this to path_open
        const zfd = fs.openSync(fds[fd - 1], 'r+')

        for (let i = 0; i < iovsLength * 2; i += 2) {
          const offset = iovs[i]
          const length = iovs[i + 1]
          const chunk = new Uint8Array(length)
          fs.readSync(zfd, chunk, 0, chunk.byteLength, totalBytesRead)
          memory.set(chunk, offset)
          totalBytesRead += chunk.byteLength
          console.log({ fd, chunk, totalBytesRead })
        }

        // TODO: move this to fd_close
        fs.close(zfd)
      }

      const dataView = new DataView(instance.exports.memory.buffer)
      dataView.setInt32(bytesReadPtr, totalBytesRead, true)

      // TODO: this gets in a loop for some reason, stopping with WASI_EBADF
      return WASI_EBADF
    }
Enter fullscreen mode Exit fullscreen mode


I verified chunk is set correctly, and totalBytes increments correctly. I also tried reading the whole file. Here it is, in context. What am I doing wrong?

Collapse
 
ndesmic profile image
ndesmic

From the current github code only thing that jumped out was line 288. The iovs basically describe multiple buffers of potentially varying lengths. So where you start reading in the file is not (i * length), you need to accumulate the lengths of all the previous iovs buffers to know where to start reading for the current io vector.

Also the github link seems to work for me.

Thread Thread
 
konsumer profile image
David Konsumer

This is good advice. I sort of stepped away from WASI in my game-engine (exposing simpler log and readFile functions) but I'd like to go back to it, so I will follow your advice. Thanks!

The github is still a dead link for me, I mean the one at the bottom of the article:
github.com/ndesmic/wasm-cross/blob...

Maybe the repo is private?

Thread Thread
 
ndesmic profile image
ndesmic

You're right it's been private this whole time 😅