Proposal: Async/Await JS API
This proposal was developed in collaboration with @fmccabe, @thibaudmichaud, @lukewagner, and @kripken, along with feedback from the Stacks Subgroup (with an informal vote approving advancing it to Phase 0 today). Note that, due to time constraints, the plan is to have a very quick (i.e. 5 minute) presentation and vote to advance this to Phase 1 on August 3rd. To facilitate that, we strongly encourage people to raise concerns here ahead of time so that we can determine if there are any major concerns that would merit pushing the presentation+vote back to a later date with more time.
The purpose of this proposal is to provide relatively efficient and relatively ergonimic interop between JavaScript promises and WebAssembly but working under the constraint that the only changes are to the JS API and not to core wasm. The expectation is that the Stack-Switching proposal will eventually extend core WebAssembly with the functionality to implement the operations we provide in this proposal directly within WebAssembly, along with many other valuable stack-switching operations, but that this particular use case for stack switching had sufficient urgency to merit a faster path via just the JS API. For more information, please refer to the notes and slides for the June 28, 2021 Stack Subgroup Meeting, which details the usage scenarios and factors we took into consideration and summarizes the rationale for how we arrived at the following design.
UPDATE: Following feedback that the Stacks Subgroup had received from TC39, this proposal only allows WebAssembly stacks to be suspended—it makes no changes to the JavaScript language and, in particular, does not indirectly enable support for detached asycn/await in JavaScript.
This depends (loosely) on the js-types proposal, which introduces WebAssembly.Function as a subclass of Function.
Interface
The proposal is to add the following interface, constructor, and methods to the JS API, with further details on their semantics below.
interface Suspender {
constructor();
Function suspendOnReturnedPromise(Function func); // import wrapper
// overloaded: WebAssembly.Function suspendOnReturnedPromise(WebAssembly.Function func);
WebAssembly.Function returnPromiseOnSuspend(WebAssembly.Function func); // export wrapper
}
Example
The following is an example of how we expect one to use this API. In our usage scenarios, we found it useful to consider WebAssembly modules to conceputally have "synchronous" and "asynchronous" imports and exports. The current JS API supports only "synchronous" imports and exports. The methods of the Suspender interface are used to wrap relevant imports and exports in order to make "asynchronous", with the Suspender object itself explicitly connecting these imports and exports together to facilitate both implementation and composability.
WebAssembly (demo.wasm):
(module
(import "js" "init_state" (func $init_state (result f64)))
(import "js" "compute_delta" (func $compute_delta (result f64)))
(global $state f64)
(func $init (global.set $state (call $init_state)))
(start $init)
(func $get_state (export "get_state") (result f64) (global.get $state))
(func $update_state (export "update_state") (result f64)
(global.set (f64.add (global.get $state) (call $compute_delta)))
(global.get $state)
)
)
Text (data.txt):
19827.987
JavaScript:
var suspender = new Suspender();
var init_state = () => 2.71;
var compute_delta = () => fetch('data.txt').then(res => res.text()).then(txt => parseFloat(txt));
var importObj = {js: {
init_state: init_state,
compute_delta: suspender.suspendOnReturnedPromise(compute_delta)
}};
fetch('demo.wasm').then(response =>
response.arrayBuffer()
).then(buffer =>
WebAssembly.instantiate(buffer, importObj)
).then(({module, instance}) => {
var get_state = instance.exports.get_state;
var update_state = suspender.returnPromiseOnSuspend(instance.exports.update_state);
...
});
In this example, we have a WebAssembly module that is a very simplistic state machine—every time you update the state, it simply calls an import to compute a delta to add to the state. On the JavaScript side, though, the function we want to use for computing the delta turns out to need to be run asynchronously; that is, it returns a Promise of a Number rather than a Number itself.
We can bridge this synchrony gap by using the new JS API.
In the example, an import of the WebAssembly module is wrapped using suspender.suspendOnReturnedPromise, and an export is wrapped using suspender.returnPromiseOnSuspend, both using the same suspender.
That suspender connects to the two together.
It makes it so that, if ever the (unwrapped) import returns a Promise, the (wrapped) export returns a Promise, with all the computation in between being "suspended" until the import's Promise resolves.
The wrapping of the export is essentially adding an async marker, and the wrapping of the import is essentially adding an await marker, but unlike JavaScript we do not have to explicitly thread async/await all the way through all the intermediate WebAssembly functions!
Meanwhile, the call made to the init_state during initialization necessarily returns without suspending, and calls to the export get_state also always returns without suspending, so the proposal still supports the existing "synchronous" imports and exports the WebAssembly ecosystem uses today.
Of course, there are many details being skimmed over, such as the fact that if a synchronous export calls an asynchronous import then the program will trap if the import tries to suspend.
The following provides a more detailed specification as well as some implementation strategy.
Specification
A Suspender is in one of the following states:
- Inactive - not being used at the moment
- Active[
caller] - control is inside theSuspender, withcallerbeing the function that called into theSuspenderand is expecting anexternrefto be returned - Suspended - currently waiting for some promise to resolve
The method suspender.returnPromiseOnSuspend(func) asserts that func is a WebAssembly.Function with a function type of the form [ti*] -> [to] and then returns a WebAssembly.Function with function type [ti*] -> [externref] that does the following when called with arguments args:
- Traps if
suspender's state is not Inactive - Changes
suspender's state to Active[caller] (wherecalleris the current caller) - Lets
resultbe the result of callingfunc(args)(or any trap or thrown exception) - Asserts that
suspender's state is Active[caller'] for somecaller'(should be guaranteed, though the caller might have changed) - Changes
suspender's state to Inactive - Returns (or rethrows)
resulttocaller'
The method suspender.suspendOnReturnedPromise(func)
- if
funcis aWebAssembly.Function, then asserts that its function type is of the form[t*] -> [externref]and returns aWebAssembly.Functionwith function type[t*] -> [externref]; - otherwise, asserts that
funcis aFunctionand returns aFunction.
In either case, the function returned by suspender.suspendOnReturnedPromise(func) does the following when called with arguments args:
- Lets
resultbe the result of callingfunc(args)(or any trap or thrown exception) - If
resultis not a returned Promise, then returns (or rethrows)result - Traps if
suspender's state is not Active[caller] for somecaller - Lets
framesbe the stack frames sincecaller - Traps if there are any frames of non-suspendable functions in
frames - Changes
suspender's state to Suspended - Returns the result of
result.then(onFulfilled, onRejected)with functionsonFulfilledandonRejectedthat do the following:- Asserts that
suspender's state is Suspended (should be guaranteed) - Changes
suspender's state to Active[caller'], wherecaller'is the caller ofonFulfilled/onRejected -
- In the case of
onFulfilled, converts the given value toexternrefand returns that toframes - In the case of
onRejected, throws the given value up toframesas an exception according to the JS API of the Exception Handling proposal
- In the case of
- Asserts that
A function is suspendable if it was
- defined by a WebAssembly module,
- returned by
suspendOnReturnedPromise, - returned by
returnPromiseOnSuspend, - or generated by creating a host function for a suspendable function
Importantly, functions written in JavaScript are not suspendable, conforming to feedback from members of TC39, and host functions (except for the few listed above) are not suspendable, conforming to feedback from engine maintainers.
Implementation
The following is an implementation strategy for this proposal. It assumes engine support for stack-switching, which of course is where the main implementation challenges lie.
There are two kinds of stacks: a host (and JavaScript) stack, and a WebAssembly stack. Every WebAssembly stack has a suspender field called suspender. Every thread has a host stack.
Every Suspender has two stack-reference fields: one called caller and one called suspended.
- In the Inactive state, both fields are null.
- In the Active state, the
callerfield references the (suspended) stack of the caller, and thesuspendedfield is null - In the Suspended state, the
suspendedfield references the (suspended) WebAssembly stack currently associated with the suspender, and thecallerfield is null.
suspender.returnPromiseOnSuspend(func)(args) is implemented by
- Checking that
suspender.callerandsuspended.suspendedare null (trapping otherwise) - Letting
stackbe a newly allocated WebAssembly stack associated withsuspender - Switching to
stackand storing the former stack insuspender.caller - Letting
resultbe the result offunc(args)(or any trap or thrown exception) - Switching to
suspender.callerand setting it to null - Freeing
stack - Returning (or rethrowing)
result
suspender.suspendOnReturnedPromise(func)(args) is implemented by
- Calling
func(args), catching any trap or thrown exception - If
resultis not a returned Promise, returning (or rethrowing)result - Checking that
suspender.calleris not null (trapping otherwise) - Let
stackbe the current stack - While
stackis not a WebAssembly stack associated withsuspender:- Checking that
stackis a WebAssembly stack (trapping otherwise) - Updating
stackto bestack.suspender.caller
- Checking that
- Switching to
suspender.caller, setting it to null, and storing the former stack insuspender.suspended - Returning the result of
result.then(onFulfilled, onRejected)with functionsonFulfilledandonRejectedthat are implemented by- Switching to
suspender.suspended, setting it to null, and storing the former stack insuspender.caller -
- In the case of
onFulfilled, converting the given value toexternrefand returning it - In the case of
onRejected, rethrowing the given value
- In the case of
- Switching to
The implementation of the function generated by creating a host function for a suspendable function is changed to first switch to the host stack of the current thread (if not already on it) and to lastly switch back to the former stack.
Is it possible to expose an API that receive an async function/generator(sync or async) then turn it into a suspendable function?
Can you clarify, maybe with some pseudocode or a use case, what you mean? I want to make sure I give you an accurate answer.
Is the intention that Suspender will be a part of JS or it's a separate API? Is it exclusively for wasm (WebAssembly.Suspender)? It looks to me that this proposal should be discussed in TC39.
It is specifically NOT intended to affect JS programs. More precisely, trying to suspend a JS function will result in a trap. We have gone to some trouble to ensure this. However, I can raise it with Shu-yu to get his opinion.
Sorry, @chicoxyzzy, I see that I forgot to include some context/updates from the Stacks Subgroup. The older stack-switching proposals were written with the expectation that you should be able to capture JavaScript/host frames in suspended stacks. However, we received feedback from people in TC39 that there was concern this would too drastically affect the JS ecosystem, and we received feedback from host implementers that there was concern not all host frames would be able to tolerate suspension. So the Stacks Subgroup has since been ensuring designs only capture WebAssembly(-related) frames in suspended stacks, and this proposal satisfies that property. I updated the OP to include this important note.
It is great to see progress here. Are there any examples of how this would be used in the ESM integration for Wasm?
The bad news is that, because this is all in the JS API, you cannot simply import an ESM wasm module and get this stack-switching support for promises. The good news is that you can still use ESM modules with this API, just with some JS ESM modules as glue.
In particular, you set up three ESM modules: foo-exports.js, foo-wasm.wasm, and foo-imports.js. The foo-imports.js module creates the suspender, uses it to wrap all the "asynchronous" promise-producing imports needed by foo-wasm.wasm, and exports the suspender and those imports. foo-wasm.wasm then imports all the "asynchronous" imports from foo-imports.js and all the "synchronous" imports directly from their respective modules (or, of course, you could also proxy them through foo-imports.js, which could export them without wrapping). Lastly, foo-exports.js imports the suspender from foo-imports.js, imports the exports of foo-wasm.wasm, wraps the "asynchronous" exports using the suspender, and then exports the (unwrapped) "synchronous" exports and the wrapped "asynchronous" exports. Clients then import from foo-exports.js and never directly touch (or need knowledge of) foo-wasm.wasm or foo-imports.js.
It's an unfortunate hurdle, but was the best we could achieve given the constraint of not modifying core wasm. We are aiming to ensure, though, that this design is forwards compatible with the proposal extending core wasm in such a way that, when that proposal ships, you can swap out these three modules for the one extended-wasm module and no one can semantically tell the difference (modulo file renaming).
Was that understandable, and do you think it would serve your needs (albeit awkwardly)?
I understand the need for wrapping, at least while WebAssembly.Module type Wasm imports are not yet possible (and hopefully they will be in due course).
More specifically though, I was wondering if there was scope for decorating these patterns at all in the ESM integration so that both sides of the suspender glue might be more managed. For example if there was some metadata that linked the exported and imported functions in the binary format, the ESM integration could interrogate that and match up the dual import / export wrapping suspender functions internally as part of the integration layer based on certain predictable rules.
Ah. At present, no such plan is in place. Feedback I had received was that there was a desire to not change ESM integration either. In short, the hope is that eventually all this will be possible in core wasm, and so we want this proposal to leave as small a footprint as possible.
Feedback I had received was that there was a desire to not change ESM integration either
Can you ellaborate on where this feedback is coming from? There is a lot of scope to extend the ESM integration with higher level integration semantics, a space I don't feel has been fully explored hence why I bring it up. I have not heard of resistance to improving this area in the past. Seeing this as an area for sugaring can be a benefit to JS developers in allowing direct Promise imports / exports.
It is worth noting that this proposal does hinder the ability for a single JS module in a cycle to be both the importer and importee to a Wasm module which can still work at the moment for function imports thanks to JS cycle function hoisting in the ESM integration, but would not support this cycle hoisting with a Suspender expression wrapper around the imported function.
I got this impression from @lukewagner. I agree there is scope to extend ESM integration, but my understanding is that this requires changes/extensions to the wasm file—which we were trying to avoid (as part of the small-footprint goal)—so we did not want such changes/extensions to be part of this proposal. Of course, if such changes/extensions were added to the ESM proposal, those would ideally complement this proposal so that one would not need the JS wrapper modules to get the functionality this proposal offers.
I misread @Jack-Works's comment, have adjusted my comment above.
Thanks @RossTate for the clarifications, yes I'm suggesting exploring the possibility matching these import and export suspension contexts via metadata in the binary itself to inform host integrations, but not expecting that in the MVP by any means. I'm also just taking the opportunity to point out the ESM integration is a space that might benefit from sugar more generally, separately to the base JS API.
To be clear, the challenge I pointed out was that any options we added to WebAssembly.instantiate() (or new versions of WebAssembly.instantiate() with new parameters) would also have to somehow show up when wasm was loaded via ESM-integration, not that ESM-integration was immutable.
Ah, cool, so we have more flexibility regarding ESM than I realized, should the need arise. Thanks for correcting my misunderstanding.
It sounds like we're talking about some kind of custom section to specify how certain exported Wasm functions should show up to JS as Promise-based APIs, and maybe conversely how imports from Wasm can be converted from JS Promise-based APIs to some kind of stack switching. Am I understanding correctly?
I like this idea. I suspect we will find ourselves wanting an analogous custom section for Wasm GC/JS-ESM integration (or part of the same one). I'm not sure to what extent this custom section might be cross-language, but in both cases, it's probably a little less universal than interface types, and also tends to be used within a component, not just between them.
Does anyone want to write up some kind of gist or README describing a basic design for this custom section?
It sounds like that is a possible option. As you mention, similar options have been discussed in the GC proposal, such as in WebAssembly/gc#203. JS-integration is tentatively scheduled to be discussed in the GC subgroup tomorrow, so it might be good to keep the possible connection to this proposal in mind during that discussion (or it might prove to be unrelated, depending on how the discussion goes).
I made some small modifications to the example to get it working in node and thought it might help other people trying to understand these features. In particular, I fixed the original wat psuedocode into actual wat. It is also necessary to wrap compute_delta in a WebAssembly.Function. This works in node v18.x with the flags --experimental-wasm-stack-switching and --experimental-wasm-type-reflection.
demo.wat
(module
(import "js" "init_state" (func $init_state (result f64)))
(import "js" "compute_delta" (func $compute_delta (result f64)))
(global $state (import "js" "global") (mut f64))
(func $init (global.set $state (call $init_state)))
(start $init)
(func $get_state (export "get_state") (result f64) (global.get $state))
(func $update_state (export "update_state") (result f64)
global.get $state
call $compute_delta
f64.add
(global.set $state)
(global.get $state)
)
)
demo.js
const fs = require("fs");
const fsPromises = require("fs/promises");
async function main() {
const suspender = new WebAssembly.Suspender();
const init_state = () => 2.71;
const compute_delta = () =>
fsPromises
.readFile("data.txt", { encoding: "utf8" })
.then((txt) => parseFloat(txt));
const wrapped_compute_delta = new WebAssembly.Function(
{ parameters: [], results: ["externref"] },
compute_delta
);
const importObj = {
js: {
init_state: init_state,
compute_delta: suspender.suspendOnReturnedPromise(wrapped_compute_delta),
global: new WebAssembly.Global({ value: "f64", mutable: true }, 0),
},
};
const buffer = fs.readFileSync("demo.wasm");
const { module, instance } = await WebAssembly.instantiate(buffer, importObj);
const get_state = instance.exports.get_state;
console.log(instance.exports.update_state);
const update_state = suspender.returnPromiseOnSuspend(
instance.exports.update_state
);
console.log("get_state", get_state());
console.log("update_state", await update_state());
console.log("get_state", get_state());
console.log("update_state", await update_state());
console.log("get_state", get_state());
}
main();
You should review the latest version of this proposal at https://github.com/WebAssembly/js-promise-integration/blob/main/proposals/js-promise-integration/Overview.md
Yeah that explains why this only worked in node v18 but not in node v19...
By the way, this is extremely exciting work! It solves all sorts of headaches. I am looking forward to using it once it starts appearing in stable releases of node/browsers.
Closing this issue since there's now a corresponding proposal and repo in place: https://github.com/WebAssembly/js-promise-integration/