8000 Wasm: Language feature for the JavaScript Promise Integration proposal (JSPI) · Issue #5064 · scala-js/scala-js · GitHub
[go: up one dir, main page]

Skip to content

Wasm: Language feature for the JavaScript Promise Integration proposal (JSPI) #5064

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

Closed
sjrd opened this issue Oct 30, 2024 · 10 comments
Closed
Assignees
Labels
language Affects language semantics. wasm Applies to the WebAssembly backend only
Milestone

Comments

@sjrd
Copy link
Member
sjrd commented Oct 30, 2024

Motivation

The JavaScript Promise Integration proposal (JSPI) for WebAssembly adds a superpower to WebAssembly, compared to JavaScript. It allows Wasm code to perform synchronous calls to Promise-returning JS functions, and suspend when the Promise is not resolved yet. This is a power basically equivalent to Virtual Threads (aka green threads, coroutines, etc.), something that is not possible to implement with JavaScript alone. Basically a non-blocking await(promise) function.

I successfully leveraged JSPI with Scala.js-on-Wasm in a hobby project to dramatically simplify the internal API :
sjrd/funlabyrinthe-scala@18bc1f2
However, to achieve it, I had to textually post-process the main.js file generated by Scala.js, hacking and slashing its abstractions to build my own. I used my internal knowledge of how the linker generates code to do this. This is not something we're supposed to do, obviously, and it will likely break when I next uprade Scala.js.

It would be nice to have dedicated language support to leverage JSPI from Scala.js code.

Proposal

There are two sides to JSPI : importing a WebAssembly.Suspending function, and wrapping an export into a WebAssembly.promising call.

Promising Exports

We need a way to express that an exported def must be wrapped in a WebAssembly.promising call. Note that the Wasm function fitself must be given to promising(f). Currently Scala.js wraps every exported function in a JavaScript-defined function to protect its abstractions. This is why we cannot currently call promising in user-space.

I propose a distinct @JSExportPromising annotation, to be used like @JSExportTopLevel on defs:

object Container {
  @JSExportPromising("name", "module.js")
  def myPromisingMethod(x: Int, y: String): String = ???
}

As seen from JavaScript, it would return a js.Promise[String], following the API transformation applied by WebAssembly.promising.

Suspending Imports

On the other side, we need a way to express that we import a JS function as wrapped into a new WebAssembly.Suspending(f) constructor. Here as well, the resulting Suspending instance must be directly imported into Wasm. Since Scala.js generates custom arrow functions for all imports to protect its abstractions, once again this cannot be achieved in user-space.

I see two possibilities here.

The first one is simpler from the Scala.js language specification point of view. The second one is closer to the JSPI API design.

First option: offer a single primitive await:

def await[A](p: js.Promise[A]): A = throw new Error("stub")

This can be implemented as a unique JS helper of the form

"await": new WebAssembly.Suspending((x) => x),

When calling any JS function that returns a js.Promise in its API, we can follow up with a call to this primitive to exploit JSPI and give us the result.

Second option: offer a generic @JSImportSuspending annotation for @js.native defs:

@js.native
@JSImportSuspending("readFile", "node:fs")
def readFile(f: String, charset: String): String = js.native

This is closer to the spirit of the JSPI API design, but it means we also need an @JSGlobalSuspending, a variant with a globalFallback, etc.

Implementation concerns

On the call path between a promising export and a suspending import call, there cannot be any JavaScript frame. This cannot reasonably be checked statically nor dynamically. However, it will be checked by the engine at run-time, which will trap if the condition is not upheld. There is currently no way to test ahead of time, and since a trap is not catchable from Wasm, we cannot recover from that. That is quite annoying, since otherwise in fastLinkJS our linker guarantees that the code it generates never traps. For example, even a testing framework would have no way of catching this condition; it will crash instead.

Design concerns

This feature is unique to the combination of JavaScript and Wasm. JS alone cannot do it, nor can Wasm alone. Therefore, this feature would be unique to Scala.js-on-Wasm! Given the current experimental status of our Wasm backend, it's not something we can really do today. However, it gives such a new superpower that I believe we should already start considering how we could offer that superpower to our users.

@sjrd sjrd added language Affects language semantics. wasm Applies to the WebAssembly backend only labels Oct 30, 2024
@sjrd
Copy link
Member Author
sjrd commented Nov 11, 2024

I have given this some more thought. I don't think we need @JSExportPromising any more than @JSImportSuspending after all.

WebAssembly.promising(f) works for any f that was the result of coercing a Wasm ref.func to a JS value. That includes the obvious (export (func ...)), but also any time a ref.func is passed as an argument to a JS function. This means that we can also have a unique operation suspendable on the Scala side, probably looking like this:

def suspendable[A](operation: => A): js.Promise[A] =
  throw new Error("stub")

That would define a Wasm func that takes operation as argument and calls its apply method. That func would then be passed to WebAssembly.promising(...) to get a JS function that returns a js.Promise[A]. Finally we call that function with the operation as argument to get back the js.Promise[A].

This is essentially what I implemented in the hack in my personal project. From an API point of view it's nicer because it does not force the developer to export anything. If we do want to export the result, that is a one-liner of the form:

@JSExportTopLevel("myPromisingFunc")
def myPromisingFunc(x: Int): js.Promise[String] =
  suspendable { myComputation(x) }

So overall, from a language API perspective, this whole feature would boil down to two primitive functions: await and suspendable.

At the IR level, await can fit in a JSUnaryOp.

suspendable is more complicated:

  • We don't want it to actually mention scala.Function0 (the IR type of the by-name parameter) since that would make the IR Scala-specific.
  • It cannot be a JS Closure either, because that would insert a JS frame by design behind the WebAssembly.promising function call, which is not allowed by JSPI.
  • We could use java.util.function.Supplier instead.
  • But if we do add TypedClosures in the IR (see Introduce NewLambda to synthesize instances of SAM types. #5003), then we can take a typed closure of 0 argument to any.

Any of the above solutions makes suspendable also fit in a JSUnaryOp. Alternatively, we can make a dedicated IR node for it, which can then contain the body and captures.

@sjrd
Copy link
Member Author
sjrd commented Nov 26, 2024

Interestingly, #5082 uses JSPI to implement async/await in the Wasm backend. If we did have #5082, then maybe the only thing we need for full, unrestricted support of JSPI is to let js.await be used anywhere instead of being restricted inside a js.async block. When linking on JS, unrestricted JSAwaits would be flagged as linking errors; but on Wasm, they would be fine.

@gzm0
Copy link
Contributor
gzm0 commented Nov 30, 2024

Just to check my understanding of JSPI:

I would be surprised if they are optimized in any specific way, because by spec each await must happen in a separate microtask of the job queue.

(from #5079 (comment))

Not being forced to have a microtask tick is essentially the superpower we are granted here, correct? (or does the import interface spec force the tick?).

On the surface, it also looks like we can suspend in any function (not just ones as marked async), but IIUC, if we want to have a static guarantee that we do not trap (or otherwise fail) on suspend, we essentially need to introduce a similar "flag" on functions. (arguably, not being strictly forced to have that flag and use other means to not trap is also a superpower).

@sjrd
Copy link
Member Author
sjrd commented Nov 30, 2024

Not being forced to have a microtask tick is essentially the superpower we are granted here, correct? (or does the import interface spec force the tick?).

No, that's at best something that could be more efficient. Anyway, even JSPI's Suspending functions suspend and resume at best on the next tick as well. Under the hood it uses the same Await(p) "spec function" as the one used by JS' await keyword.

(arguably, not being strictly forced to have that flag and use other means to not trap is also a superpower).

That is the superpower. Not so much that we don't need to flag functions, but that we can suspend an arbitrary number of stack frames! The functions in the middle of the stack (between the entry through promising and the exit through Suspending) don't need to know that they can be suspended. In fact, they can either be called from code that does not suspend, in which case they won't suspend.

This mechanism solves the "what color is your function?" problem. Every function can act blue or red, depending on who calls them. That's the semantic superpower. In addition, it does that efficiently (through suspension instead of CPS-transforming every function).

On the surface, it also looks like we can suspend in any function (not just ones as marked async), but IIUC, if we want to have a static guarantee that we do not trap (or otherwise fail) on suspend, we essentially need to introduce a similar "flag" on functions.

If we want that guarantee, then we're back to coloring functions, indeed. For the traps, I managed to convince the proposal champions to throw catchable exceptions instead. So that's good, because we can still promise to generate code that never traps. We don't need to guarantee that no exceptions will be thrown. (It is possible to build abstractions that guarantee it with Scala 3's research "capture checking", notably as done in the research Gears library; but that's out of the scope of what we need to think about.)

@tanishiking
Copy link
Contributor
tanishiking commented Nov 30, 2024

It’s great to see that #5082 only provides the js.async and js.await APIs, without exposing "low-level" APIs like suspendable to end users 👍

the only thing we need for full, unrestricted support of JSPI is to let js.await be used anywhere instead of being restricted inside a js.async block. When linking on JS, unrestricted JSAwaits would be flagged as linking errors; but on Wasm, they would be fine.

I’m curious about how "letting " let js.await be used anywhere instead of being restricted inside a js.async block" looks like.

AFAIK, js.async is translated into a Wasm function that is exported and wrapped by WebAssembly.promising, while js.await(...) is a JS function wrapped by WebAssembly.suspending and imported into Wasm (at least in the current state of #5082).
If we allow js.await outside a js.async block when linking to Wasm, the generated code could result in a RuntimeError (Uncaught RuntimeError: attempting to suspend without a WebAssembly.promising export on Chrome). Do you have a plan to additional change to the PR?

@sjrd
Copy link
Member Author
sjrd commented Nov 30, 2024

No, I don't think we should attempt to check further at the Scala.js level. With the changes in WebAssembly/js-promise-integration#55 the RuntimeError will be a SuspendError error instead (a normal JS exception that we can catch, for example in a testing framework). Beyond that, we would leave it to the responsibility of the developer to write correct code (like we do for all sorts of APIs that can throw exceptions).

As I mentioned, capture checking would provide a way to build safer abstractions on top of those building blocks, but that's beyond our scope.

@gzm0
Copy link
Contributor
gzm0 commented Nov 30, 2024

Beyond that, we would leave it to the responsibility of the developer to write correct code (like we do for all sorts of APIs that can throw exceptions).

I'm somewhat with @tanishiking on this one. Or at least, I feel we should:

  • Try to build a deeper understanding of how this case relates to other cases where we "resort" to runtime exceptions, or
  • Have a solid plan of what to do if we realize we need to restrict this capability further.

@sjrd
Copy link
Member Author
sjrd commented Nov 30, 2024

It doesn't seem bad at all to me to have a predictable exception. We have plenty of APIs, both Scala.js-specific and not, that work that way. Examples include division by zero, string concatenation of a js.Symbol, access out of bounds of a collection. Arguably all of these can be detected with a straightforward test ahead of attempting the operation. For erroneous conditions that cannot be straightforwardly tested ahead of time, we have even worse APIs with unspecified behavior: notably, all sorts of modifications to collections while an iteration is in progress. Here we will get (with SuspendError) a predictable exception instead of unspecified behavior.

The problem with restricting this is that currently known solutions either

  • restrict too much, preventing one from actually exercising the superpower offered by JSPI (functions need to be colored again)
  • or too little, resulting in the exception being thrown anyway in some cases, so we still have no guarantee.

Finding a solution that hits the sweet spot in the middle is precisely what capture checking set out to achieve, but that is a multi-year research project. js.async would introduce a capability to await, and js.await would require that capability. Capture checking ensures that the capability does not leak out of the js.async block (even through captures, which is the origin of the project name).

@gzm0
Copy link
Contributor
gzm0 commented Nov 30, 2024

but that is a multi-year research project

I think this is the key insight here. So I agree, we must find a way to expose this at least as a low-level API (and if we actually get exceptions, that's indeed more than enough IMO).

I wonder if we should offer a "coloring" based sugar at the language level for users that just want async (IIUC #5082 essentially does that in its current state).

sjrd added a commit to sjrd/scala-js that referenced this issue Feb 10, 2025
With the JavaScript Promise Integration (JSPI), there can be as
many frames as we want between a `WebAssembly.promising` function
and the corresponding `WebAssembly.Suspending` calls. The former
is introduced by our `js.async` blocks, and the latter by calls to
`js.await`.

Normally, `js.await` must be directly enclosed within a `js.async`
block. This ensures that it can be compiled to a JavaScript `async`
function and `await` expression.

We introduce a compiler option to allow "orphan awaits". This way,
we can decouple the `js.await` calls from their `js.async` blocks.
The generated IR will then only link when targeting WebAssembly.

There must still be a `js.async` block *dynamically* enclosing any
`js.await` call (on the call stack), without intervening JS frame.
If that dynamic property is not satisfied, a
`WebAssembly.SuspendError` gets thrown. This last point is not yet
implemented in Node.js at the time of this commit; instead such a
situation traps. The corresponding test is therefore currently
ignored.
sjrd added a commit to sjrd/scala-js that referenced this issue Feb 11, 2025
With the JavaScript Promise Integration (JSPI), there can be as
many frames as we want between a `WebAssembly.promising` function
and the corresponding `WebAssembly.Suspending` calls. The former
is introduced by our `js.async` blocks, and the latter by calls to
`js.await`.

Normally, `js.await` must be directly enclosed within a `js.async`
block. This ensures that it can be compiled to a JavaScript `async`
function and `await` expression.

We introduce a compiler option to allow "orphan awaits". This way,
we can decouple the `js.await` calls from their `js.async` blocks.
The generated IR will then only link when targeting WebAssembly.

There must still be a `js.async` block *dynamically* enclosing any
`js.await` call (on the call stack), without intervening JS frame.
If that dynamic property is not satisfied, a
`WebAssembly.SuspendError` gets thrown. This last point is not yet
implemented in Node.js at the time of this commit; instead such a
situation traps. The corresponding test is therefore currently
ignored.
sjrd added a commit to sjrd/scala-js that referenced this issue Mar 16, 2025
With the JavaScript Promise Integration (JSPI), there can be as
many frames as we want between a `WebAssembly.promising` function
and the corresponding `WebAssembly.Suspending` calls. The former
is introduced by our `js.async` bloc
8000
ks, and the latter by calls to
`js.await`.

Normally, `js.await` must be directly enclosed within a `js.async`
block. This ensures that it can be compiled to a JavaScript `async`
function and `await` expression.

We introduce a compiler option to allow "orphan awaits". This way,
we can decouple the `js.await` calls from their `js.async` blocks.
The generated IR will then only link when targeting WebAssembly.

There must still be a `js.async` block *dynamically* enclosing any
`js.await` call (on the call stack), without intervening JS frame.
If that dynamic property is not satisfied, a
`WebAssembly.SuspendError` gets thrown. This last point is not yet
implemented in Node.js at the time of this commit; instead such a
situation traps. The corresponding test is therefore currently
ignored.
@sjrd
Copy link
Member Author
sjrd commented Mar 16, 2025

There is vote scheduled for April 8 to move JSPI to Phase 4. At that point it will be basically stable.

sjrd added a commit to sjrd/scala-js that referenced this issue Mar 18, 2025
With the JavaScript Promise Integration (JSPI), there can be as
many frames as we want between a `WebAssembly.promising` function
and the corresponding `WebAssembly.Suspending` calls. The former
is introduced by our `js.async` blocks, and the latter by calls to
`js.await`.

Normally, `js.await` must be directly enclosed within a `js.async`
block. This ensures that it can be compiled to a JavaScript `async`
function and `await` expression.

We introduce a compiler option to allow "orphan awaits". This way,
we can decouple the `js.await` calls from their `js.async` blocks.
The generated IR will then only link when targeting WebAssembly.

There must still be a `js.async` block *dynamically* enclosing any
`js.await` call (on the call stack), without intervening JS frame.
If that dynamic property is not satisfied, a
`WebAssembly.SuspendError` gets thrown. This last point is not yet
implemented in Node.js at the time of this commit; instead such a
situation traps. The corresponding test is therefore currently
ignored.
sjrd added a commit to sjrd/scala-js that referenced this issue Mar 24, 2025
With the JavaScript Promise Integration (JSPI), there can be as
many frames as we want between a `WebAssembly.promising` function
and the corresponding `WebAssembly.Suspending` calls. The former
is introduced by our `js.async` blocks, and the latter by calls to
`js.await`.

Normally, `js.await` must be directly enclosed within a `js.async`
block. This ensures that it can be compiled to a JavaScript `async`
function and `await` expression.

We introduce a compiler option to allow "orphan awaits". This way,
we can decouple the `js.await` calls from their `js.async` blocks.
The generated IR will then only link when targeting WebAssembly.

There must still be a `js.async` block *dynamically* enclosing any
`js.await` call (on the call stack), without intervening JS frame.
If that dynamic property is not satisfied, a
`WebAssembly.SuspendError` gets thrown. This last point is not yet
implemented in Node.js at the time of this commit; instead such a
situation traps. The corresponding test is therefore currently
ignored.
@sjrd sjrd closed this as completed in 2ff3bc6 Apr 6, 2025
sjrd added a commit that referenced this issue Apr 6, 2025
Fix #5064: Add `js.async { ... js.await(p) ... }`, and support JSPI on WebAssembly.
@sjrd sjrd self-assigned this Apr 21, 2025
@sjrd sjrd added this to the v1.19.0 milestone Apr 21, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
language Affects language semantics. wasm Applies to the WebAssembly backend only
Projects
None yet
Development

No branches or pull requests

3 participants
0