Kobako: Exchanging Memory in WebAssembly
This article is translated by AI, if have any corrections please let me know.
The past few posts covered how MessagePack was chosen, but interacting through WebAssembly works a little differently from interacting with a native extension.
The reason is that what we’re building is a sandbox to isolate untrusted code, which means the operations are essentially close to one-directional. Code running inside the sandbox cannot touch any of the host’s memory, so the method calls we originally had in mind need some special handling.
Heterogeneous Environments
RPC (Remote Procedure Call) often comes to mind as a design because we’re dealing with heterogeneous environments, so we need a common language that works in both, along with a way to communicate.
With MessagePack settled as the common language, what’s left to decide is how to communicate. Until then I only had a rough understanding that WebAssembly offers two mechanisms, Host Function (calling out from the inside) and Exported Function (calling in from the outside).
So in practice it ends up behaving a lot like network transport, and much like how an RPC call works.
- The sender encodes the request into the common format
- The sender writes the request to a designated location
- The receiver reads it from that designated location
- The receiver decodes the sender’s request and handles it
At least that’s how it looks in theory, but in reality WebAssembly works a little differently.
Security Constraints
Since WebAssembly can serve as a sandbox option, that implies a certain amount of security machinery is already in place, namely that only the host can access the guest’s memory while the guest cannot touch host memory at all. That leaves us needing to make the interaction happen inside the guest’s memory.
On the other hand, WebAssembly is expressed in a form close to assembly, so the intuitive rpc_call(request) doesn’t actually work. Instead we have to handle two steps ourselves.
- Write to memory and get a pointer
- When making the call, use the pointer to say where to start reading
Conceptually, though, it’s very close to how RPC works. We work in a block of memory shared between host and guest, doing something like the following
- The guest wants to call
Host::Logger.info(...) - Pack
Host::Logger.info(...)into MessagePack format - Write it to memory address
0x01 - Call the host’s
rpc_call(0x01, size) - The host handles the request and packs the result into MessagePack format
- Write it to memory address
0x31 - Call the guest’s
rpc_reply(0x31, size)
This block of memory is managed by the guest, so all of the host’s memory stays isolated from it. That also reduces memory leak problems, and the operations are basically safe.
I originally thought a WebAssembly call could pass data like objects around. After checking, it turned out the data can only live in memory, and all a call can carry is numbers like an address and a length. A single call can only return one value on top of that, so the address and the length need to be packed together, which is why it takes this detour.
Return Values
Because I wanted to leave stdout and stderr for the guest’s own output, I couldn’t determine the returned value by parsing stdout or stderr directly, and needed another way to handle it.
Beyond that, WebAssembly is usually invoked in command mode, a bit like running echo "HI" where you see stdout printed on screen and get back an exit code: 0. For what Kobako is aiming at, though, I want to see a Ruby value come back, and that approach doesn’t meet the requirement.
So it also needs to be switched to reactor mode, which works differently from command mode. It’s closer to using the compiled file as a library, so once the memory initialization has been called, it stops and waits for the user, and it doesn’t clean up memory when it finishes, leaving the timing entirely up to the user.
That fits Kobako’s situation well. stdout and stderr go back to being purely the guest’s output, with no need to depend on parsing them, and the real return value sits in WebAssembly’s memory, the same memory region we used earlier to simulate RPC calls.
Once the run finishes, it has to be fetched back out of WebAssembly with one Exported Function call, but that buys a lot more flexibility along the way.
In the past I mostly compiled WebAssembly and called it directly, but these experiments from the proof-of-concept stage all became the foundation for Kobako’s later development.
Looking back now, RPC was essentially just an analogy. The host and guest actually share the same thread and the same memory, without even a cross-process boundary between them, so Kobako ended up not using RPC in its naming.
Enjoyed this article? Buy me a milk tea 🧋