-
Notifications
You must be signed in to change notification settings - Fork 284
Description
The pain point
WASI p1 lacked network capabilities, so Wasm runtimes that want to support it have to rely on custom binding to open sockets or expose higher-level networking calls. This forces "compatibility from the top", the only libraries that can be used without modifications need to expose a sans-io path. This entrypoint can be called with an adapter around a custom object that binds on the custom bindings of the runtime. For example, in Rust using hyper "standard entrypoint":
let client = hyper:Client::new();
let mut response = client.get(uri).await?;
compiles, but breaks at runtime. It is however still possible to make it work using conn
directly:
let custom_tcp = custom_sdk::net::tcp_connect(uri);
let (mut request_sender, connection) = hyper::conn::handshake(custom_tcp).await?;
tokio::spawn(async move {
if let Err(e) = connection.await {
eprintln!("Error in connection: {}", e);
}
});
let request = ...
let mut response = request_sender.send_request(request).await?
This is impractical as many libraries do not expose a sans-io entrypoint, or one much less ergonomic than the "standard" on. For all languages, a separate SDK needs to expose an object using the custom bindings and implementing standard/common traits/interfaces when the language supports them (e.g. in Rust io::{Read, Write}
, tokio:{AsyncRead, AsyncWrite}
). It must be wrapped to satisfy the exact library specifics in many cases. This duplicates the effort by runtimes, it's time-consuming, error-prone, language-dependent, offers minimal portability of existing code, and generally vendor-locking.
The hope
I'm really excited about WASI p2, and in particular, its sockets world. In the same way that compilers integrating WASI p1 made compiling many existing projects possible, I hope the adoption of WASI p2 in compilers and standard libraries will continue this trend!
But I'm worried about the adoption rate. In particular, I really like the capability-based model of WASI: calls to the network (like tcp-socket::start-bind
and tcp-socket::start-connect
) having to pass down a network handle (borrow<network>
). Unfortunately, this is not how current software is developed, and it clashes with the signature of all standard libraries I know. So a "user" may be given a network handle, but has no way to pass it through the multiple layers of a library down to the TcpStream::connect
call (or equivalent).
What is your stance on this issue? Do you have a roadmap for improving compatibility with existing codebases?
I’m interested in contributing WASI p2 support in major compilers and standard libraries, but I’d like to avoid duplicating efforts or worse, going against your vision.
I came up with two proposals to try and solve this issue and would appreciate your feedback on them.
Global or thread-local scoped context
Compilers would expose intrinsics like:
fn __set_resource_network(handle: Option<i32>);
fn __get_resource_network() -> Option<i32>;
Those would get/set a mutable global or thread local (for wasm using the thread proposal) context. This context could be extended with other resources if needed. This allows a "user" given a network resource to register it in this global context and make it accessible to all subsequent networking calls done by the standard library. Here is an example of what it could look like in user code:
pub fn run(network: i32, uri: String) {
let client = hyper:Client::new();
// this fails as no network resource was set
println!("{:?}", client.get(&uri).await);
__set_resouce_network(network);
// this succeeds with the default unmodified hyper Client
println!("{:?}", client.get(&uri).await);
}
Note here that I'm using hyper 0.14, which may not be the best example since it doesn't rely on the std::net::TcpStream
to open connections, but on the separate socket2
. But I think the point stands still, the real world is a messy and multiple PRs in multiple projects will be necessary to have a good coverage. But this "compatibility from the bottom" scales much better, there are disproportionally less libraries to patch and add a "WASI p2 target platform" (projects that directly make systemcalls and usually already have split implementations for unix/windows/mac...) rather than downstream projects which require a sans-io refactor.
The use of a stateful mutable global is a bit inelegant, but this would be easy to implement in all compilers with a similar API for all languages.
Implicit scoped context propagation
Maybe a better more "pure" approach would be to use a scoped implicit context passed down in each function holding all currently set capabilities.
It could be implemented directly at the wasm level, adding a "ctx" pointer as first parameter to all internal functions. This parameter would be passed down to each function called. The intrinsics __set_resource_*
could be implemented in several ways. One would be to clone the current context, set the resource and pass down the new pointer from there. Crucially this new context would be freed at the end of the scope, the previous context being restored.
Only the "internal functions" signatures would be modified. The exported functions would start by creating an empty context, and the imported functions would be called with only their relevant parameters (some may come from the context).
Here is a simple example:
(module
(import "env" "get_network_resource" (func $get_network_resource (result i32)))
(func $__set_resource_network (param $ctx i32) (param $net i32) (result i32) ...)
(func $__get_resource_network (param $ctx i32) (result i32) ...)
(func $__free_context (param $ctx i32) ...)
(func $fetch (param $ctx i32)
;; do something that ultimately calls the WASI 2 tcp-socket/start-connect with the network resource found in $ctx
)
(func $run (export "run") (local $ctx i32)
;; build default context
i32.const 0
local.set $ctx
;; pass context down
local.get $ctx
call $internal
;; pass context down
local.get $ctx
call $fetch ;; fails
)
(func $internal (param $ctx i32) (local $new_ctx i32) (local $net i32)
call $get_network_resource
local.set $net
;; new scoped context with network resource
local.get $ctx
local.get $net
call $__set_resource_network
local.set $new_ctx
;; pass new context down
local.get $new_ctx
call $fetch ;; succeeds
local.get $new_ctx
call $__free_context
)
)
Note that this is highly unoptimized for clarity. Small contexts (e.g. if it only holds a network resource), could live on the stack or locals, avoiding allocation/deallocation, and be optimized away when a function and its callees don't use it.
This is, I think, more elegant and may play nicer with threads and async proposals. It also retains better the capability-based model. But it is harder to implement in compilers as it may need a big refactor, the context contaminating all internal functions.
Interestingly, this implementation describes a language-agnostic way of supporting scoped context, but some languages already have a similar concept at the language level like Odin, Scala, and Jai. For those languages, the compiler patch may be much lighter as it would only require a standard library update, the network-based calls getting the network resource from the language implicit context directly (I haven't done extensive research on those languages, so it may be way more difficult than that). On the other hand, this "wasm level" approach probably won't work for interpreted languages (at least those that embed their interpreter in the wasm).
Let me know if this topic has already been discussed or if there's a direction you'd like contributors to take. I’m happy to help push changes across compiler ecosystems if there's alignment.