The JavaScript Promise Integration (JSPI) API is an API that bridges the gap between synchronous WebAssembly applications and asynchronous Web APIs. It does so by mapping synchronous calls issued by the WebAssembly application into asynchronous Web API calls, suspending the application and resuming it when the asynchronous I/O operation is completed. Crucially, we are able to achieve this with very few changes to the WebAssembly application itself.
This proposal makes no changes to the JavaScript language nor to the WebAssembly language. There are no new WebAssembly instructions or types specified. Semantically, all of the changes outlined are at the boundary between WebAssembly and JavaScript.
Many modern APIs on the Web are asynchronous in nature -- mediated by Promises. Asynchronous APIs operate by splitting the offered functionality into two separate parts: the initiation of the operation and its resolution; with the latter coming some time after the first. Most importantly, the application continues execution after kicking off the operation; and is then notified when the operation completes.
For example, the fetch API allows Web applications to access the contents associated with a URL; however, the fetch function does not directly return the results of the fetch; instead it returns a Promise. The connection between the fetch response and the original request is reestablished by attaching a callback to that Promise. The callback function can inspect the response, collect the data (if it is there of course) and re-enter the Web application.
On the other hand, many applications that are compiled into WebAssembly originate from languages such as C/C++ which do not have mature coroutining features and where the APIs used are typically synchronous in nature. In the example of calling fetch, a legacy application would typically block until the result of the fetch is available. Web applications are strongly discouraged from blocking in this way; because blocking the main thread carries the risk of degrading the user experience. As a result there can be a significant mismatch between the application and web APIs.
This proposal allows a WebAssembly application to interact with JavaScript APIs and functions that are oriented around Promises. Furthermore, it allows the WebAssembly application to invoke so-called Promise-bearing imports and access the value of the Promise, without having to explicitly manage the asynchronous callbacks normally associated with Promises.
There are two elements in the JSPI API: the WebAssembly.Suspending constructor and the WebAssembly.promising function.
The WebAssembly.Suspending object is used to mark imports to a WebAssembly module such that, when called, the WebAssembly code will suspend until the Promise returned by the import is resolved.1
When, at some point later, the Promise is resolved, and the WebAssembly module is resumed -- by the browser's event queue task runner -- then the value of the resolved Promise becomes the value of the WebAssembly call to the import.
Again, if the Promise is rejected, then instead of resuming the WebAssembly module with the value, an exception will be propagated into the suspended computation.
As a result, a WebAssembly module can import a Promise returning JavaScript function so that the WebAssembly code can treat a call to it as a synchronous call.
The WebAssembly.promising function is used to wrap an exported WebAssembly function into one that returns a Promise -- where the returned value from the exported function becomes the basis of resolving the Promise.2
The promising function and the Suspending object form a pair; when a WebAssembly computation is suspended due to a call to a Suspending import, it is the call to the promising export that is continued -- in the first instance. I.e., a call to a promising export finishes when the first call to a Suspending import results in the WebAssembly code being suspended. The value returned by the promising export is also a Promise; that will be resolved only when the wrapped export finally returns (or throws an exception).
Of course, in general, a particular call to a marked export may require multiple calls to
Suspendingimports, with multiple suspensions. However, other than the first one, all subsequent suspensions are visible only to the browser event queue task runner: the host application only sees thePromiseinitially created and is reactivated only when the wrapped export finally returns (or throws).
Since they form a pair, it is not expected for an unmatched module to be meaningful: if a marked import suspends but the corresponding export (whose execution led to the call to the suspending import) is not marked then the engine is expected to trap. If an export function is marked, but its execution never results in a call to a marked import, then the marked function returns a fully resolved Promise.
Only WebAssembly computations may be suspended using JSPI; this is enforced by requiring that only WebAssembly frames are active between the call to a promising function and any call to a Suspending wrapped import.
Considering the expected applications of this API, we can consider two simple scenarios: that of a so-called legacy C application -- which is written in the style of a non-interactive application using synchronous APIs for reading and writing files -- and the responsive C application; where the application was typically written using an eventloop internal architecture but still uses synchronous APIs for I/O.
Our first example seems quite trivial, with the WebAssembly module:
(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)
(local $delta f64)
(local.set $delta (call $compute_delta))
(global.set (f64.add (global.get $state) (local.get $delta) ))
(global.get $state)
)
)In this example, we have a WebAssembly module that is a very simple state machine—driven from JavaScript. Whenever the JavaScript client code wishes to update the state, it invokes the exported update_state function. In turn, the WebAssembly update_state function calls an import, compute_delta, 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. In addition, we want to implement the compute_delta import by using JavaScript fetch to get the delta from the url www.example.com/data.txt.
This expectation is reified in the JavaScript code for compute_delta:
var compute_delta = () =>
fetch('https://example.com/data.txt')
.then(res => res.text())
.then(txt => parseFloat(txt));In order to prepare our code for asynchrony, we wrap the compute_delta function using the WebAssembly.Suspending constructor. The complete import object looks like:
var init_state = () => 2.71;
var importObj = {js: {
init_state: init_state,
compute_delta: new WebAssembly.Suspending(compute_delta)}};In addition to preparing the import, we must also handle the export side. The process of wrapping exports is a little different to wrapping imports; in part because we prepare imports before instantiating modules and we wrap exports afterwards:
var sampleModule = new WebAssembly.Instance(demoBuffer,importObj);
var promise_update = WebAssembly.promising(sampleModule.exports.update_state)At runtime, a call to the JavaScript function promise_update will get a Promise. As part of resolving that Promise, the WebAssembly exported $update_state function is called, which results in a call to the $compute_delta import. That, in turn, uses fetch to access a remote file, and parse the result in order to give the actual floating point value back to the WebAssembly module. Since we use a WebAssembly.Suspending wrapped function to implement $compute_delta, the import call will be suspended.
When the fetch completes, the result is parsed -- which will also cause a suspension since getting the text from a Response also results in a Promise. This too will cause the application to be suspended; but when that finally is resumed the text is parsed and the result returned as a float to $compute_delta.
After updating the internal state, the original export $update_state returns, which causes the Promise originally returned by promise_update to be resolved. At that point, anyone awaiting that Promise will be given the value returned by $update_state.
A responsive application is able to respond to new requests even while suspended for existing ones. Note that we are not concerned with multi-threaded applications (which can also be responsive): only one computation is expected to be active at any one time and all others would be suspended. Typically, such responsive applications are already crafted using an eventloop style architecture; even if they still use synchronous APIs.
In fact, our example above is already technically re-entrant! We can call promise_update even before other calls to promise_update have returned. However, JSPI does not guarantee that the updates are completed in any particular order: it is up to the application developer to ensure that this is safe. In our specific case, it does not matter because all the fetch calls result in the same floating point number being accumulated to the $state global variable.
Not all applications can equally tolerate being reentrant in this way. Certainly, languages in the C family do not make this straightforward. In fact, an application would typically have to have been engineered appropriately, by, for example, ensuring that each call to a suspending import does not interfere with globally shared state.
However, desktop applications, written for operating systems such as Mac OS and Windows, are often already structured in terms of an event loop that monitors input events and schedules UI effects. Such an application can often make good use of JSPI: perhaps by removing the application's event loop and replacing it with the browser's event loop.
[Exposed=*]
partial namespace WebAssembly {
Function promising(Function wasmFun);
};
[LegacyNamespace=WebAssembly, Exposed=*]
interface Suspending {
constructor(Function jsFun);
};The Suspending object's role is primarily to annotate a function in a way that enables the WebAssembly.instantiate function to implement the import in a special way. Note that WebAssembly.Suspending has no externally visible attributes other than those inherited from Object. However, it does have an internal slot -- [[wrappedFunction]] -- which is referenced in the specifics of the algorithms below.
The WebAssembly.promising function takes a WebAssembly function, as exported by a WebAssembly instance, and returns a JavaScript function that returns a Promise. The returned Promise will be resolved by the result of invoking the exported WebAssembly function.
The WebAssembly.promising function takes a WebAssembly function -- i.e., not a JavaScript function -- and returns a JavaScript function that evaluates wasmFun and returns a Promise with the result:
- Let
wasmFuncbe the exported WebAssembly function that is passed to theWebAssembly.promisingfunction, - if
wasmFundoes not contain a[[FunctionAddress]]hidden slot throw aTypeErrorexception. - create a function that will, when called with arguments
args:- Let
promisebe a newPromiseconstructed as though by thePromise(fn) constructor, wherefnis a function of two argumentsacceptandrejectthat:- lets
contextbe a new execution context, and sets the running execution context tocontext(pushing the existing running execution context onto the execution context stack). - lets
resultbe the result of callingwasmFunc(args)(or any trap or thrown exception) - releases the execution
context, which includes releasing any execution resources associated with the context. - If
resultis not an exception or a trap, calls theacceptfunction argument with the appropriate value. - If
resultis an exception, or if it is a trap, calls therejectfunction with the raised exception.
- lets
- Returns
promisetocaller
- Let
- Return created function as value of
WebAssembly.promising
Note that, if the function wasmFunc suspends (by invoking a Promise returning import), then the promise will be returned to the caller before wasmFunc returns. When wasmFunc completes eventually, then promise will be resolved -- and one of accept or reject will be invoked by the browser's microtask runner.
We use the Suspending constructor to signal to WebAssembly that a given import should cause a suspension of WebAssembly execution.
The WebAssembly.Suspending constructor takes a JavaScript Function as an argument which is embedded in the Suspending object as the value of the [[wrappedFunction]] hidden slot.
Note that
jsFunis expected to be a JavaScript function. This allows us to ignore certain so-called corner cases in the usage of JSPI: in particular there is no special handling of WebAssembly functions passed toWebAssembly.Suspending.
- If IsCallable(jsFun) is
false, throw aTypeErrorexception. - Let suspendingProto be
WebAssembly.Suspending.%prototype% - Let susp be the result of
OrdinaryObjectCreate(suspendingProto) - Assign the
[[wrappedFunction]]internal slot ofsusptojsFun - Return susp
The most direct way that external functions can be accessed from a WebAssembly module is via imports.3 Therefore, we modify the read-the-imports algorithm to account for imports annotated as Suspending.
We replace the section dealing with functions:
3.4. If externtype is of the form func functype ...
with:
3.4. If externtype is of the form func functype
- If
$IsCallable$(v) istrue- If
vhas a[[FunctionAddress]]internal slot, and therefore is an Exported Function,- Let
funcaddrbe the value ofv's[[FunctionAddress]]internal slot.
- Let
- Otherwise,
- Create a host function from
vandfunctype, and letfuncaddrbe the result.
- Create a host function from
- If
- If
vhas a[[wrappedFunction]]internal slot:- Let
funcbev's[[wrappedFunction]]slot. - Assert
$IsCallable$(func). - Create a suspending function from
funcandfunctype, and letfuncaddrbe the result.
- Let
- If
$IsCallable$(v) isfalseandvdoes not have a[[wrappedFunction]]internal slot then throw aLinkErrorexception. - Let
*index*be the number of external functions inimports. This value*index*is known as the index of the host functionfuncaddr. - Let
externfuncbe the external valuefuncaddr - Append
externfunctoimports.
The suspending function is a function whose behavior is determined as follows:
- Let
contextrefer to the execution context that is current at the time of a call to the suspending function. Letwfuncbe the wrapped function that was used when creating the suspending function. - Traps if
context's state is not Active[caller] for somecaller - Let
resultbe the result of callingwfunc(args)(or any trap or thrown exception) whereargsare the additional arguments passed to the call when the imported function was called from the WebAssembly module. - If
$IsPromise(result):- Lets
framesbe the stack frames sincecaller - Traps if there are any frames of non-WebAssembly functions in
frames - Changes
context's state to Suspended - Returns the result of
result.then(onFulfilled, onRejected)with functionsonFulfilledandonRejectedthat do the following:- Asserts that
context's state is Suspended (should be guaranteed) - Changes
context'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
- Lets
- Otherwise, returns the result to the caller:
- If
resultis a normal value, return as value of suspending function - If
resultis an exception, throw exception.
- If
-
Why do we prevent JavaScript programs from using this API?
JavaScript already has a way of managing computations that can suspend. This is semantically connected to JavaScript
Promiseobjects and theasyncfunction syntax. However, a more important reason is that we do not wish to inadvertently introduce features that can affect the behavior of existing JavaScript programs. -
Why does
WebAssembly.promisingreturn a JavaScriptFunction?This allows us to be more precise in a few special cases. In particular, if two WebAssembly modules (Modules A & B) are chained together (e.g., the import of module A is provided by the export of module B) then we need precision about the interaction with JSPI:
- If the link is composed of a
Suspending/promisingpair (i.e., the module A import is wrapped withnew WebAssembly.Suspendingand the module B export is wrapped withWebAssembly.promising) then, if module B suspends (via one of its imports), then module A will also suspend. However, there will also be an additionalPromise: created from the wrapped export from module B. - If the link is direct -- without a
Suspending/promisingpair -- then the connect is transparent from the perspective of JSPI; provided that there are no JavaScript function calls in the link.
- If the link is composed of a
-
What are the changes to the API?
The primary change to the JSPI API are the removal of the
Suspenderobject and the detachment from the Type Reflection Proposal. The former change means that:- Suspending imports are always assumed to be of JavaScript functions (even if an import is actually bound to a WebAssembly function),
- when a wrapped import returns a
Promise, the computation between the innermost wrappedpromisingexport and the import is suspended. Previously, this was identified by the explicitSuspenderobject; and - the JSPI API no longer uses a modification of the
WebAssembly.Functionconstructor to convey intentions. As a result, it is no longer necessary for JavaScript code that is responsible for managing the instantiation of a WebAssembly module to be aware of the types of WebAssembly functions in imports and exports.
Footnotes
-
If the imported function did not return a
Promise, then the results will be passed directly to the WebAssembly caller -- i.e., there will be no suspension in this case. ↩ -
The English language can be somewhat ambiguous when it comes to concepts such as marking and wrapping. In order to avoid such ambiguities, we use the term marked function to denote the result of applying
WebAssembly.promisingorWebAssembly.Suspendingto a function. And we will use the term wrapped function to denote the argument function that is passed into those API calls -- i.e., the function that will be invoked as a result of invoking the marked function. ↩ -
The other way is by using a
WebAssembly.Functionconstructor, as proposed in the js-types proposal. However, the semantics ofWebAssembly.Functionalso revolve around modules and importing. ↩