How to send values bigger than i8 from JS through wasm-memory to Rust?

Currently I'm trying to send values bigger than i8 from JS through wasm-memory to Rust like so:

Rust:

// CREATE WASM MEMORY FOR RUST AND JS DIRECT USE.
const WASM_MEMORY_BUFFER_SIZE: usize = 2; // 2 SLOTS
static mut WASM_MEMORY_BUFFER: [u8; WASM_MEMORY_BUFFER_SIZE] = [0; WASM_MEMORY_BUFFER_SIZE]; // INITIALIZE WITH 0

#[wasm_bindgen] // FOR JS TO GET THE POINTER TO MEMORY
pub fn get_wasm_memory_buffer_pointer() -> *const u8 {
  let pointer: *const u8;
  unsafe {
    pointer = WASM_MEMORY_BUFFER.as_ptr();
  }
  return pointer;
}

(^ Adjusting all the u8's to u16, u32 .. works fine in Rust)

JS:

// IMPORT THE GENERATED JS MODULE
import init from "../pkg/tem.js";

// GET WASM
const runWasm = async () => {
  const rustWasm = await init("../pkg/tem_bg.wasm");
  
  // CREATE THE WASM MEMORY
  var wasmMemory = new Uint8Array(rustWasm.memory.buffer);
  console.log("wasmMemory", wasmMemory);
  
  // GET THE POINTER TO WASM MEMORY FROM RUST
  var mem_pointer = rustWasm.get_wasm_memory_buffer_pointer();
  wasmMemory[mem_pointer] =  "i8";
};
runWasm();

The problem is that an i8 is quite small, and would need to send bigger numbers through memory.

I can adjust the u8 to f.e. u32 in Rust, than set a value: WASM_MEMORY_BUFFER[0] = "i32"; Getting the pointer as an i32 in JS with the Rust funcion is also still possible.

However, in JS I cannot change var wasmMemory = new Uint8Array(rustWasm.memory.buffer);
to Uint32Array();.
Thus, unlike Rust, setting a value: wasmMemory[mem_pointer] = "i32"; doesn't work.

Can this be resolved? Since I would like to set a value bigger than i8 in JS and read that value in Rust.

WebAssembly memory is fundamentally only visible to JavaScript as a chunk of bytes. If you want to write values bigger than a byte you'll need to manually decompose them into bytes and write each byte to wasm memory in the correct order.

You'll also need to take care that you use the endianness that the Rust wasm target uses when decomposing numbers into bytes. I think it's little endian based on the wasm portability requirements but I couldn't find a rust specific reference for that immediately.

If you're just writing some numbers it may be easier to expose a function from Rust to do that so you don't have to worry about all the details.

I'm trying to look into it, see if it'll be viable.

I could give a Rust function in JS the values I want to send as parameters. But I'm changing the values every few ms. So than I would invoke that function with parameters from JS quite a lot. I was thinking that having direct access to the memory/those values would be better optimized.

WebAssembly is defined to be little-endian and JavaScript will use the endianness of your host (little-endian for x86 and ARM), so you should be able to do something like this:

// on the Rust side

const SIZE: usize = 2;
static mut WASM_MEMORY_BUFFER: [u32; SIZE] = [0; SIZE];

#[wasm_bindgen]
pub fn get_wasm_memory_buffer_pointer() -> *const u32 {
  WASM_MEMORY_BUFFER.as_ptr()
}
// on the JavaScript side

const SIZE = 2;
const memPointer = rustWasm.get_wasm_memory_buffer_pointer();

const buffer = new Uint32Array(rustWasm.memory.buffer, memPointer, SIZE);
2 Likes

That is cursed. Wasm is little endian only, so Javascript should be too. For native code people already forget that big endian exists, but having that leak to the web too with devs who don't know anything about endianness...

2 Likes

Yeah.... Good luck fixing that without breaking some random application somewhere that implicitly depended on endianness.

A lot of browser APIs use typed arrays (fetch(), AudioBuffer, etc.) so there are loads of places the host endianness can "leak" into your application.

Is there a reason not use DataView here to be portable across endianness?

Fixing it by defining it to always be little endian shouldn't break anything for js code that works on little endian and can only fix such code on big endian, right?

Yeah, I guess you could.

However, I think you would find people never use it in practice. The API is kinda clunky and gives you no easy way to view an ArrayBuffer as an array with a certain endianness, so you would be reduced to copying the data into a new array with the correct endianness every time you want to work with WebAssembly memory. It would feel like the JavaScript equivalent of using pointer arithmetic to access elements in an slice.

I agree with you, but I don't think existing big endian users would :disappointed:

XKCD: Workflow

4 Likes

Well you can, but all the addresses need to be divided by 4 (and must be divisible by 4, but rust should take care of that for you)

This is part of what I was saying in the other thread, that you need to do a whole bunch of work to get values in and out in general.

Thank you, this works! The only problem I'm running into now is that I can't read the value in JS. I set it like: buffer[0] = "i32" and can than read the value in Rust. Though reading the value the same way as setting it in JS gives me undefined.

I'm reading the other comments and responses and it seems there is more going on. Should I be concerned about it, or can I just use it like this and not worry about it? I'm trying to, but I don't really follow what else is being discussed yet.

1 Like

Reading data in JS works fine for me in completely raw WASM and node, at least?

Extremely bad code, do not use

(Note, this is all hilariously bad practice!)

// src/main.rs
#![no_main]

extern "C" {
    fn console_log(str: &str);
}

#[no_mangle]
pub unsafe fn alloc(size: usize, align: usize) -> *mut u8 {
    match std::alloc::Layout::from_size_align(size, align) {
        Ok(layout) => std::alloc::alloc(layout),
        Err(_) => std::ptr::null_mut(),
    }
}

#[no_mangle]
pub fn say_hello(name: &str) {
    unsafe { console_log(&format!("Hello, {name}!")) };
}

// test.mjs
import assert from "node:assert";
import fs from "node:fs";

// note your path will probably be different
const bytes = fs.readFileSync("./target/wasm32-unknown-unknown/debug/little-wasm.wasm");
const module = new WebAssembly.Module(bytes);
const instance = new WebAssembly.Instance(module, {
  env: {
    console_log(ptr, len) {
      const bytes = new Uint8Array(instance.exports.memory.buffer, ptr, len);
      console.log(new TextDecoder().decode(bytes));
    },
  },
});

const nameBytes = new TextEncoder().encode("Simon");
const nameLen = nameBytes.length;
const namePtr = instance.exports.alloc(nameLen, 1);
assert(namePtr);
new Uint8Array(instance.exports.memory.buffer).set(nameBytes, namePtr);
instance.exports.say_hello(namePtr, nameLen);

> cargo build --target wasm32-unknown-unknown
> node test.mjs
Hello, Simon!

The example you have of using buffer[0] implies you are trying to use module.exports.memory.buffer[0], which will not work, as Memory.buffer is an ArrayBuffer, and you can't read and write to an ArrayBuffer directly, only through a "view", such as Uint8Array or DataView.

1 Like

That makes sense.

I will have to come back to the actual code at some point since I don't really follow it yet haha :grin:

Here's the breakdown, since there's quite a bit of magic:

Rust --target wasm32-unknown-unknown will generate a .wasm file where:

  • every extern { fn foo(); } will generate a import for a function foo, and every import is in the env import module. List these with WebAssembly.Module.imports(module) call.

    • You can override this name with #[link_name = "bar"]
  • every #[no_mangle] fn foo() (anywhere!) will generate an export for a function foo in the module. List these with WebAssembly.Module.exports(module).

    • You can override the export name with #[export_name = "bar"], unsurprisingly, instead of using #[no_mangle]
  • Near as I can tell, you can't export or import WASM globals, other than the built in __data_end and __heap_base pointers?

  • WASM only supports 32 and 64 bit number types (u32, f64, etc), so every structure gets flattened to separate arguments, so as &str is a slice, which is internally represented as a pointer and length pair, it turns into a ptr, len pair.

    • But this layout is not guaranteed, so you get a "&str is not FFI-safe" warning - instead in real code, manually pass in or out the ptr: *const u8, len: usize pair, get them with as_ptr() and .len(), and get a &str back with std::str::from_utf8(std::slice::from_raw_parts(ptr, len)) (perhaps using the _unchecked variant if you can prove it won't ever not be UTF-8)
  • It will only actually generate a .wasm file if the crate type is bin or cdylib, so either using src/main.rs with #![no_main] if you're dirty, or src/lib.rs with [lib] crate-type = ["cdylib"] in Cargo.toml if you are doing the right thing works. Weirdly the latter will replace - with _ in the file name.


So, in summary, better Rust code would look like:

// declare a wrapper `console::log` for the WASM import `console_log`.
mod console {
    // Import a javascript function to log values
    // extern type doesn't really matter here, AFAICT
    extern "system" {
        #[link_name = "console_log"]
        fn _log(ptr: *const u8, len: usize);
    }

    // wrap the import with a nicer interface.
    pub fn log(str: &str) {
        // need to use unsafe for any extern fn in rust, but there's nothing unsafe
        // about the call itself, just what the external code might do.
        unsafe { _log(str.as_ptr(), str.len()) }
    }
}

// export the allocator so javascript can allocate and free memory in rust
mod alloc {
    // note that *any* pub fn 
    #[no_mangle]
    unsafe fn alloc(size: usize, align: usize) -> *mut u8 {
        match std::alloc::Layout::from_size_align(size, align) {
            Ok(layout) => std::alloc::alloc(layout),
            Err(_) => std::ptr::null_mut(),
        }
    }

    #[no_mangle]
    unsafe fn dealloc(ptr: *mut u8, size: usize, align: usize) {
        if let Ok(layout) = std::alloc::Layout::from_size_align(size, align) {
            std::alloc::dealloc(ptr, layout);
        }
    }
}


// export a function for javascript to call, using FFI-safe types
#[export_name = "say_hello"]
unsafe fn _say_hello(name_ptr: *const u8, name_len: usize) {
    // This assumes that JS passed us a valid pointer and length to some UTF-8.
    // This is not safe in general, so only do this if you also wrap up the JS side!
    let name = std::str::from_utf8_unchecked(std::slice::from_raw_parts(name_ptr, name_len));
    // Call the "real" Rust function
    say_hello(name)
}

// Do something in Rust
fn say_hello(name: &str) {
    console::log(&format!("Hello, {name}!"));
}

And the node.js code with some commentary:

// import built-in Node.js modules
import assert from "node:assert";
import fs from "node:fs";

// read the bytes of the WASM file we built in Rust
const bytes = fs.readFileSync("./target/wasm32-unknown-unknown/debug/little_wasm.wasm");

// explicitly parse and validate the WASM module...
const module = new WebAssembly.Module(bytes);
// then create an instance from that module, and an import map:
const instance = new WebAssembly.Instance(module, {
  env: {
    // this is the implementation for the extern console::_log in Rust.
    // if you don't provide it, creating the instance will fail with a LinkError
    console_log(ptr, len) {
      // grab the ArrayBuffer representing the WASM memory space.
      // in my experience, WASM doesn't like it if you keep this around,
      // though I don't know why, exactly.
      const buffer = instance.exports.memory.buffer;

      // ptr is a simple JS number which is an index into that memory space,
      // so new Uint8Array(buffer, offset, length) nicely creates a byte array
      // representing the bytes in the rust memory space backing &str that
      // we passed to console::log in say_hello:
      const bytes = new Uint8Array(buffer, ptr, len);

      // Note that these are dynamically allocated in Rust
      // due to the format!(), and will be freed (in Rust) when we return, so
      // be sure to copy them out if you want to keep it around, for example:
      //
      //   copy = new Uint8Array(bytes);
      //
      // which is shorthand for:
      //
      //   copyBuffer = new ArrayBuffer(bytes.length); // allocate new buffer
      //   copy = new Uint8Array(copyBuffer); // wrap buffer for byte access
      //   copy.set(bytes);  // copy each entry from bytes into copy

      // new TextDecoder().decode(bytes) will get the string for UTF-8 bytes.
      console.log(new TextDecoder().decode(bytes));
    },
  },
});

// Get the UTF-8 bytes for a string
const nameBytes = new TextEncoder().encode("Simon");
// be sure to get the byte length, not the string length, which is different
// for non-ASCII text.
const nameLen = nameBytes.length;

// call the rust allocator to get some temporary rust memory you are
// allowed to write to
const namePtr = instance.exports.alloc(nameLen, 1);
// check the allocation worked
assert(namePtr);

// create a Uint8Array representing the entire WASM memory space,
// then write the nameBytes starting at the address namePtr we just allocated
new Uint8Array(instance.exports.memory.buffer).set(nameBytes, namePtr);

// call the _say_hello export with a pointer, length pair
instance.exports.say_hello(namePtr, nameLen);

// free the Rust memory for nameBytes we allocated.
instance.exports.dealloc(namePtr, nameLen, 1);

Note that this is basically what wasm_bindgen is doing for you, along with all the object lifetime stuff (where it basically uses a JS array of objects and indexes in rust into it).

1 Like