Skip to content
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

Closed
RossTate opened this issue Jul 26, 2021 · 20 comments
Closed

Proposal: Async/Await JS API #1425

RossTate opened this issue Jul 26, 2021 · 20 comments

Comments

@RossTate
Copy link

RossTate commented Jul 26, 2021

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 the Suspender, with caller being the function that called into the Suspender and is expecting an externref to 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:

  1. Traps if suspender's state is not Inactive
  2. Changes suspender's state to Active[caller] (where caller is the current caller)
  3. Lets result be the result of calling func(args) (or any trap or thrown exception)
  4. Asserts that suspender's state is Active[caller'] for some caller' (should be guaranteed, though the caller might have changed)
  5. Changes suspender's state to Inactive
  6. Returns (or rethrows) result to caller'

The method suspender.suspendOnReturnedPromise(func)

  • if func is a WebAssembly.Function, then asserts that its function type is of the form [t*] -> [externref] and returns a WebAssembly.Function with function type [t*] -> [externref];
  • otherwise, asserts that func is a Function and returns a Function.

In either case, the function returned by suspender.suspendOnReturnedPromise(func) does the following when called with arguments args:

  1. Lets result be the result of calling func(args) (or any trap or thrown exception)
  2. If result is not a returned Promise, then returns (or rethrows) result
  3. Traps if suspender's state is not Active[caller] for some caller
  4. Lets frames be the stack frames since caller
  5. Traps if there are any frames of non-suspendable functions in frames
  6. Changes suspender's state to Suspended
  7. Returns the result of result.then(onFulfilled, onRejected) with functions onFulfilled and onRejected that do the following:
    1. Asserts that suspender's state is Suspended (should be guaranteed)
    2. Changes suspender's state to Active[caller'], where caller' is the caller of onFulfilled/onRejected
      • In the case of onFulfilled, converts the given value to externref and returns that to frames
      • In the case of onRejected, throws the given value up to frames as an exception according to the JS API of the Exception Handling proposal

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 caller field references the (suspended) stack of the caller, and the suspended field is null
  • In the Suspended state, the suspended field references the (suspended) WebAssembly stack currently associated with the suspender, and the caller field is null.

suspender.returnPromiseOnSuspend(func)(args) is implemented by

  1. Checking that suspender.caller and suspended.suspended are null (trapping otherwise)
  2. Letting stack be a newly allocated WebAssembly stack associated with suspender
  3. Switching to stack and storing the former stack in suspender.caller
  4. Letting result be the result of func(args) (or any trap or thrown exception)
  5. Switching to suspender.caller and setting it to null
  6. Freeing stack
  7. Returning (or rethrowing) result

suspender.suspendOnReturnedPromise(func)(args) is implemented by

  1. Calling func(args), catching any trap or thrown exception
  2. If result is not a returned Promise, returning (or rethrowing) result
  3. Checking that suspender.caller is not null (trapping otherwise)
  4. Let stack be the current stack
  5. While stack is not a WebAssembly stack associated with suspender:
    • Checking that stack is a WebAssembly stack (trapping otherwise)
    • Updating stack to be stack.suspender.caller
  6. Switching to suspender.caller, setting it to null, and storing the former stack in suspender.suspended
  7. Returning the result of result.then(onFulfilled, onRejected) with functions onFulfilled and onRejected that are implemented by
    1. Switching to suspender.suspended, setting it to null, and storing the former stack in suspender.caller
      • In the case of onFulfilled, converting the given value to externref and returning it
      • In the case of onRejected, rethrowing the given value

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.

@Jack-Works
Copy link

Is it possible to expose an API that receive an async function/generator(sync or async) then turn it into a suspendable function?

@RossTate
Copy link
Author

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.

@chicoxyzzy
Copy link
Member

chicoxyzzy commented Jul 30, 2021

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.

@fgmccabe
Copy link

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.

@RossTate
Copy link
Author

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.

@guybedford
Copy link

It is great to see progress here. Are there any examples of how this would be used in the ESM integration for Wasm?

@RossTate
Copy link
Author

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)?

@guybedford
Copy link

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.

@RossTate
Copy link
Author

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.

@guybedford
Copy link

guybedford commented Jul 30, 2021

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.

@RossTate
Copy link
Author

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.

@guybedford
Copy link

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.

@lukewagner
Copy link
Member

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.

@RossTate
Copy link
Author

Ah, cool, so we have more flexibility regarding ESM than I realized, should the need arise. Thanks for correcting my misunderstanding.

@littledan
Copy link

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?

@RossTate
Copy link
Author

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).

@hoodmane
Copy link

hoodmane commented Oct 29, 2022

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();

@fgmccabe
Copy link

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

@hoodmane
Copy link

hoodmane commented Nov 1, 2022

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.

@RossTate
Copy link
Author

Closing this issue since there's now a corresponding proposal and repo in place: https://github.com/WebAssembly/js-promise-integration/

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

9 participants