New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Proposal: Async/Await JS API #1425
Comments
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 |
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. |
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: 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. |
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 |
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 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.jsconst 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/ |
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 ofFunction
.Interface
The proposal is to add the following interface, constructor, and methods to the JS API, with further details on their semantics below.
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
):Text (
data.txt
):JavaScript:
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 usingsuspender.returnPromiseOnSuspend
, both using the samesuspender
.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 anawait
marker, but unlike JavaScript we do not have to explicitly threadasync
/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 exportget_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:caller
] - control is inside theSuspender
, withcaller
being the function that called into theSuspender
and is expecting anexternref
to be returnedThe method
suspender.returnPromiseOnSuspend(func)
asserts thatfunc
is aWebAssembly.Function
with a function type of the form[ti*] -> [to]
and then returns aWebAssembly.Function
with function type[ti*] -> [externref]
that does the following when called with argumentsargs
:suspender
's state is not Inactivesuspender
's state to Active[caller
] (wherecaller
is the current caller)result
be the result of callingfunc(args)
(or any trap or thrown exception)suspender
's state is Active[caller'
] for somecaller'
(should be guaranteed, though the caller might have changed)suspender
's state to Inactiveresult
tocaller'
The method
suspender.suspendOnReturnedPromise(func)
func
is aWebAssembly.Function
, then asserts that its function type is of the form[t*] -> [externref]
and returns aWebAssembly.Function
with function type[t*] -> [externref]
;func
is aFunction
and returns aFunction
.In either case, the function returned by
suspender.suspendOnReturnedPromise(func)
does the following when called with argumentsargs
:result
be the result of callingfunc(args)
(or any trap or thrown exception)result
is not a returned Promise, then returns (or rethrows)result
suspender
's state is not Active[caller
] for somecaller
frames
be the stack frames sincecaller
frames
suspender
's state to Suspendedresult.then(onFulfilled, onRejected)
with functionsonFulfilled
andonRejected
that do the following:suspender
's state is Suspended (should be guaranteed)suspender
's state to Active[caller'
], wherecaller'
is the caller ofonFulfilled
/onRejected
onFulfilled
, converts the given value toexternref
and returns that toframes
onRejected
, throws the given value up toframes
as an exception according to the JS API of the Exception Handling proposalA function is suspendable if it was
suspendOnReturnedPromise
,returnPromiseOnSuspend
,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 calledcaller
and one calledsuspended
.caller
field references the (suspended) stack of the caller, and thesuspended
field is nullsuspended
field references the (suspended) WebAssembly stack currently associated with the suspender, and thecaller
field is null.suspender.returnPromiseOnSuspend(func)(args)
is implemented bysuspender.caller
andsuspended.suspended
are null (trapping otherwise)stack
be a newly allocated WebAssembly stack associated withsuspender
stack
and storing the former stack insuspender.caller
result
be the result offunc(args)
(or any trap or thrown exception)suspender.caller
and setting it to nullstack
result
suspender.suspendOnReturnedPromise(func)(args)
is implemented byfunc(args)
, catching any trap or thrown exceptionresult
is not a returned Promise, returning (or rethrowing)result
suspender.caller
is not null (trapping otherwise)stack
be the current stackstack
is not a WebAssembly stack associated withsuspender
:stack
is a WebAssembly stack (trapping otherwise)stack
to bestack.suspender.caller
suspender.caller
, setting it to null, and storing the former stack insuspender.suspended
result.then(onFulfilled, onRejected)
with functionsonFulfilled
andonRejected
that are implemented bysuspender.suspended
, setting it to null, and storing the former stack insuspender.caller
onFulfilled
, converting the given value toexternref
and returning itonRejected
, rethrowing the given valueThe 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.
The text was updated successfully, but these errors were encountered: