WebAssembly / js-promise-integration

JavaScript Promise Integration
Other
56 stars 16 forks source link

Purpose of Suspender #2

Closed eqrion closed 5 months ago

eqrion commented 2 years ago

What is the purpose of a Suspender object?

From reading the overview, my understanding is that Suspender provides a re-entrancy guard to modules. Is there anything else that Suspender provides?

If that is accurate, would it instead be possible for modules that desire protection from re-entrancy to provide it themselves using a mutable global for 'isActive', with inserted traps if 'isActive' on all exports? This would have the same efficiency, with the advantage of reducing concepts needed in the JS-API.

fgmccabe commented 2 years ago

A suspender object acts to connect a suspending import with a promising export. It is not there to support re-entrancy or to prevent it. However, without the 'static' version of the API re-entrancy is effectively banned (it would result in a trap) because the suspender cannot be reused while it is in use. The static API resolves this by allowing different suspenders to be used by the different 'entries'. However, there are additional blocks in the way of reentrancy: namely that tool chains like emscriptem use shadow stacks. That will have to be resolved in addition (probably by giving a special memory block for each entry).

eqrion commented 2 years ago

A suspender object acts to connect a suspending import with a promising export. It is not there to support re-entrancy or to prevent it.

Could you elaborate on what this allows or disallows versus the alternative of having the promising export being found dynamically at the point of an import trying to suspend?

RossTate commented 2 years ago

When composing systems using this feature, without suspenders there can be ambiguity as to where up the stack a suspension should be turned into a promise. While we could instead specify a (global) policy for which candidate to pick when there are multiple options, any policy is effectively guesswork and thereby can cause unexpected behavior when composing systems.

(Good question, by the way. At present the rationale is not in the documentation, though it was discussed in subgroup meetings some time last year.)

eqrion commented 2 years ago

When composing systems using this feature, without suspenders there can be ambiguity as to where up the stack a suspension should be turned into a promise. While we could instead specify a (global) policy for which candidate to pick when there are multiple options, any policy is effectively guesswork and thereby can cause unexpected behavior when composing systems.

Okay, that seems reasonable to me. If I understand correctly, each suspender would function similarly to a distinct control tag from the typed continuations proposal.

Regarding reentrancy then, would it be possible to drop the restriction that at most one stack is suspended per suspender at a time? This would still accomplish the goal of determining which wrapped export to suspend to from a wrapped import, without the need for two different wrapping APIs.

For specification purposes, the Suspender would move the Suspended and Active[caller] states to an internal Stack object, which the Suspender would maintain references to. The Suspender would contain a stack of active Stack objects, and the top would be used as the destination for any suspending imports.

fgmccabe commented 2 years ago

One point that is important to understand, when a computation resumes, it will not always be (almost never?) at the top of that stack. That means that you would have to introduce more machinery. In addition, supporting reentrancy will require additional tooling support (e.g. memory for the shadow stack); so I am not sure what you would save by not having the WebAssembly.* version of the API.

eqrion commented 2 years ago

One point that is important to understand, when a computation resumes, it will not always be (almost never?) at the top of that stack. That means that you would have to introduce more machinery.

I'm not sure I follow. What does it mean for a computation to resume somewhere that's not the top of the stack?

In addition, supporting reentrancy will require additional tooling support (e.g. memory for the shadow stack); so I am not sure what you would save by not having the WebAssembly.* version of the API.

Yes, reentrancy won't be supported without toolchains also supporting it. I just want to understand if the Suspender.* API's could allow reentrancy and therefore reduce the API surface of this proposal. Toolchains that don't want reentrancy could manually disallow it in their entry points.

fgmccabe commented 2 years ago

Imagine a 'responsive' web app with its core in wasm. A button pressed on the page results in a call to an export in the core. Reentrancy would allow the user to click the same button (or different ones) multiple times. Two clicks to a button, result in two fetches of data, and the first comes back first (say). In your stack of stacks, the first computation will be below the second one (because it was initiated first). But when the first fetch comes back, the first click will need to be resumed before the second click (otherwise it's not responsive). However, now you are no longer resuming the top computation. (You cant change the stack to a queue, because the second click might have come back before the first one) Apologies if this was all obvious.

eqrion commented 2 years ago

Imagine a 'responsive' web app with its core in wasm. A button pressed on the page results in a call to an export in the core. Reentrancy would allow the user to click the same button (or different ones) multiple times. Two clicks to a button, result in two fetches of data, and the first comes back first (say). In your stack of stacks, the first computation will be below the second one (because it was initiated first). But when the first fetch comes back, the first click will need to be resumed before the second click (otherwise it's not responsive). However, now you are no longer resuming the top computation. (You cant change the stack to a queue, because the second click might have come back before the first one) Apologies if this was all obvious.

The top of the 'stack of Stack objects' determines which Stack object to suspend to from a wrapped import. When a promise resolves, it would need to resume the Stack that was suspended when it was created.

My description of how you can change the Overview is a bit vague. I can write up a version that describes what I am proposing using typed-continuations if that would clarify things.

But I want to go back a step here. The question, "what is the purpose of a Suspender?", was given as "just to disambiguate which returnPromiseOnSuspend to suspend to from a suspendOnReturnedPromise".

Why can we not then allow reentrancy with the Suspender.* API? This would remove the need to specify and implement a separate API.

I understand that toolchains will need to support reentrancy themselves, if they want. I also believe that they'll need to guard against it themselves even if Suspender disallows it, as there will be other ways to get into a module than just wrapped exports. I'm asking about this just as a way to simplify the API surface and make it easier for us to implement.

fgmccabe commented 2 years ago

I understand the motivation for reducing the API surface. However, it is our opinion that this suggestion would come at significant implementation cost. We have already implemented a prototype of the Suspender interface, and don't anticipate the WebAssembly interface being much additional work. On the other hand, managing a pool of stacks in a suspender is likely to be problematic; in our estimation.

fgmccabe commented 2 years ago

BTW, I would appreciate your taking a look at the latest PR. It has a interesting twist in it.

eqrion commented 2 years ago

I understand the motivation for reducing the API surface. However, it is our opinion that this suggestion would come at significant implementation cost.

On the other hand, managing a pool of stacks in a suspender is likely to be problematic; in our estimation.

Can you elaborate on what this significant implementation cost is, or why this is problematic? That's really what I'm trying to understand here.

BTW, I would appreciate your taking a look at the latest PR. It has a interesting twist in it.

Sure, I'll take a look at that the next chance I get.

fgmccabe commented 2 years ago

It is essential that a suspending computation be accurately connected between export/import/suspend/resume etc. We are currently using the Suspender object to do that - i.e., it is doing a kind of double duty: acting both as the delimiters and as a container for an actual stack. Your approach would require either significant management of a pool of stacks (if they are managed inside a Suspender) or surfacing another entity (to represent the stack itself). On the other hand, just having Suspender objects will work for both normal asynchronous IO use cases and reentrant use cases.

eqrion commented 2 years ago

Coming back to this again after talking with Francis/Ross, and then thinking about it.

My understanding is that the primary use-case for Suspender is to link an export wrapper (which can suspend) to an import wrapper (which returns the promise wrapping the suspended stack). This can be used to have an export wrapper suspend past an import wrapper to a higher up import wrapper on the stack.

If Suspender did not exist this would imply some policy, such as nearest on stack, for finding the import wrapper to suspend to. I was told that any policy would likely not be sufficient for all users.

So I’m now trying to work through a use-case that needs two different Suspender’s. The scenario I’ve heard is two modules, X & Y, separately compiled and linked. Each of these module’s would have their own Suspender that their imports/exports are compiled with.

We could then run into the following stack (growing down):

  1. X export wrapper (suspender X)
  2. X.0
  3. Y export wrapper (suspender Y)
  4. Y.0
  5. X.1 // direct callee from Y.0
  6. X import wrapper (suspender X)
  7. JS import that returns a promise

When frame 7 returns, frame 6 would attempt to suspend and have two choices of where to suspend to (frame 1 or 3). Using the same suspender ‘rule’, it would suspend to frame 1 instead of frame 3.

Frame 3 is a WA.returnPromiseOnSuspend wrapper, which either returns a promise or the results of the export (see issue #4). This would give it a func-type of [*] -> externref in current wasm in order to capture that polymorphism when imported into wasm to be called.

The problem I’m running into here is that I can’t think of a use case for X.0 (a wasm function) to be directly calling this function, because it cannot await the promise and it cannot access the normal results (which are wrapped in externref).

So I can see that having suspenders is more expressive, but I cannot figure how to use multiple suspenders in a realistic way.

RossTate commented 2 years ago

Thanks for the good question!

So, first, let's say what shouldn't happen: we should not suspend to frame 3. That would likely result in the program continuing to run in an (insecure) corrupted state. (An explicit run-time error would be preferable to this.)

Second, suppose Y used a different way to create the promise it returns. For example, a CPS-based compiler can easily be extended to generate promises without using suspenders. Or one could use something like asyncify. If Y didn't use a suspender, then I think we'd all expect this example to succeed by suspending up to frame 1. By making this succeed even when Y does use a suspender, we achieve abstraction: how Y implements its contract does not affect X's suspender's behavior (assuming both implementations meet the same contract).

The first point above illustrates how the design uses suspenders to achieve security, and the second point illustrates how the design achieves composability.

Now for the question you actually asked: why would X use a promise-generating function without immediately awaiting the resulting promises? The answers here are largely the same for if X were written in JavaScript rather than wasm. For example, X might intentionally kick off a bunch of simultaneous workloads and then Promise.all/any/race them to achieve some more concurrency/parallelism. Or X might work on JavaScript objects in general, say by implementing some data-store.

Moving things further up, another reason for multiple suspenders is to support multiple suspenders within a wasm program. This generally requires a program to be reason about multiple shadow stacks, but it also lets a program service multiple concurrent requests "simultaneously". That's the purpose of the multi-suspender extension we've recently restarted discussing.

Hope that was a useful answer!

eqrion commented 2 years ago

Second, suppose Y used a different way to create the promise it returns. For example, a CPS-based compiler can easily be extended to generate promises without using suspenders. Or one could use something like asyncify. If Y didn't use a suspender, then I think we'd all expect this example to succeed by suspending up to frame 1. By making this succeed even when Y does use a suspender, we achieve abstraction: how Y implements its contract does not affect X's suspender's behavior (assuming both implementations meet the same contract).

The first point above illustrates how the design uses suspenders to achieve security, and the second point illustrates how the design achieves composability

Ah, interesting. Focusing on, "how Y implements its contract does not affect X's suspender's behavior (assuming both implementations meet the same contract).". My understanding is that this proposal already does not meet this criteria of composability. If Y were implemented in JS, then X's suspender would no longer work because JS frames are not suspendable.

Now for the question you actually asked: why would X use a promise-generating function without immediately awaiting the resulting promises? The answers here are largely the same for if X were written in JavaScript rather than wasm. For example, X might intentionally kick off a bunch of simultaneous workloads and then Promise.all/any/race them to achieve some more concurrency/parallelism. Or X might work on JavaScript objects in general, say by implementing some data-store.

I think I was actually trying to get at a slightly different question (but phrased it poorly, so I'm going to clarify). Why would a wasm module X, directly import and call an export wrapper from Y? The export wrapper's signature is challenging to use from wasm because of results being boxed into an externref. And even when the export wrapper returns a promise, the module X is itself using js-promise-integration likely because it prefers to not deal with promises at all in wasm code and just be suspended.

RossTate commented 2 years ago

There are really four modules here: X and Y, as well as A (X + wrapping suspender) and B (Y + wrapping suspender).

As you pointed out, whoever hooked up A and B needs to know B is not written in JS for this arrangement to work. There's nothing we can do about that. But we can strive to do is make it so that that's all they need to know about B. That is, we can strive for abstraction/composability within the wasm + suspenders space. So, while the person hooking up A and B does need to know about X (because it's passing X's wrapped import to B), they don't need to know about Y.

With that in mind, A and B are each promise-generating modules. For the examples I gave, e.g. using Promise.all, A would use B's generated promises in a first-class manner. A doesn't really care about how those promises are generated (e.g. using asyncify or suspenders), so it too doesn't need to know about Y. That is, A isn't thinking "I'm calling an export wrapper form Y"; A is thinking "I'm calling a promise-generating function", and there are a variety of reasons it can do that.

Raising the reasoning up a bit: in order to prevent the bad thing where we suspend up to frame 3, we seem to either need multiple suspenders or need to trap upon attempting to enter frame 3. The latter solution exposes how B is implemented and, in a sense, punishes the maintainer of B (who might not be the same as the maintainer of A) for switching to using suspenders, so we went for the former solution.

eqrion commented 2 years ago

As you pointed out, whoever hooked up A and B needs to know B is not written in JS for this arrangement to work. There's nothing we can do about that. But we can strive to do is make it so that that's all they need to know about B. That is, we can strive for abstraction/composability within the wasm + suspenders space.

I'm not sure that this level of abstraction/composability is useful on the Web. My mental model of composing 'web modules' (that may be JS or wasm) is that the 'ABI'/'conventions' (none of this is well-defined) is JS. Similar to how C is the convention for native. Within a 'web module' that's implemented with wasm (potentially multiple wasm modules linked together), it's expected that everything is the same language and toolchain and it can use whatever ABI it wants. On the boundary though, a JS interface is presented. The boundary maintains invariants and converts wasm values to/from JS values.

I believe the issue I have with the example I gave is that in frame 5 the web module A is re-entered (likely from a callback) and then tries to suspend itself without knowing who has called it and therefore whether the suspension will work or not. If the caller was JS, it won't work. If the caller is wasm, as in this case, the suspension may work, but it's not clear that its caller (Y) expects this to happen and may break. If Y was used to only calling JS modules previously, it may not know that a callback could suspend its caller.

A 'web module' that is implemented with wasm modules should not try to suspend unless it knows that it will catch and wrap the suspension, so as to provide a proper JS interface. In this example, that would mean that the callback/frame 5 should be wrapped so as to catch the suspension that its caller might not be able to handle.

If this convention is followed, a suspending export will always find the correct wrapping import as the nearest one on the stack. Using the ability of a suspender to 'suspend through' an unknown web module, seems like a footgun that is not needed for these toolchains.

eqrion commented 2 years ago

With that in mind, A and B are each promise-generating modules

This is just a side note, but WA.returnPromiseOnSuspend does not always return a promise which means that it cannot be used to seamlessly swap in for a previous async JS function (which always return promises) in the JS interface of a web module. If this is a desired use-case, you should return a resolved promise in the case the function returns normally.

fgmccabe commented 2 years ago

Following up on Ross's comment: a critical success factor for JS-Promise integration (and any subsequent core wasm stack switching capability) is that it may not be used to suspend JS. This, by itself, breaks any assumptions of 'independence of implementation'.

RossTate commented 2 years ago

Thanks for the side note! That is indeed something that we have been reconsidering.

We wanted a design that supported the common case of suspending a wasm stack. We also wanted people who maintain wasm modules/libraries to be able to change to using suspenders without breaking compatibility with clients. That led to the current design. The example with B as a library and A as a client illustrates how the current design facilities that.

I understand that this API can be abused and misused. Unfortunately, the only way to prevent that seems to be to switch to an opt-in design. But even for opt-in designs, the research has found that you need multiple suspenders for the system to be compositional/secure/abstract. So the current design is essentially the untyped version of the typed design for which you can prove strong guarantees.

tlively commented 7 months ago

To summarize my view of this issue after the most recent stack subgroup meeting:

  1. Having Suspender is only more expressive than always suspending to the most recent JS frame in cases where there are multiple Wasm modules calling into each other via imports/exports instrumented/wrapped with the WebAssembly.Function JSPI API.
  2. The only concrete use case of this extra expressivity so far is to use JSPI to implement OCaml effect handlers. This use case is not productionized AFAIK, will be obsolete once we have core stack switching, and could be fulfilled without suspenders with some extra work in JS.
  3. The primary intended use case of the Suspender is gradual porting of modules from JS to Wasm without other modules having to be aware of the difference. I have never heard of this modular porting being done in practice. Modular porting is generally infeasible because JS and Wasm module interfaces are so different.
  4. Having Suspender objects requires global code transformations in current toolchains. Removing Suspender would allow us to remove this DevX stumbling block and would simplify the JSPI configuration API as well as toolchain implementations.

Removing Suspender seems like a clear-cut decision.

RossTate commented 7 months ago

Thanks for holding discussion off until after the holiday.

Before delving into semantic complications, I think it would be useful to get on the same page about toolchain benefits, since I get the impression that we have different perceptions about what the toolchain impact is.

Without suspenders, you can take an arbitrary compiled wasm module, whose inputs and exports expect synchronous interactions and have no knowledge of JSPI, and a list of imports and exports that need to be asynchronized, and from that generate something analogous to a module instance whose listed exports generate promises and suspend if a listed import returns a promise.

With suspenders, with the same inputs (i.e. arbitrary compiled wasm module and list of imports and exports), you can generate the same thing (putting aside semantic technicalities).

That's my understanding at least. So, not only is no global code transformation required for suspenders (from what I can tell), but no language-specific tooling is required either. The requirements seem to be the same to me. So can you clarify why your understanding is that it requires global code transformation?

tlively commented 7 months ago

To use JSPI with Suspenders, we run this Binaryen transformation to wrap imports and exports: https://github.com/WebAssembly/binaryen/blob/main/src/passes/JSPI.cpp. This significantly slows down link time for debug builds of large Emscripten applications, which would otherwise not have to run Binaryen at all. Removing suspenders would fix this DevX issue by letting us use the original module unmodified.

RossTate commented 7 months ago

Oh, so it's not an issue with global transformation; it's a build-speed issue. Right now you support this by modifying a compiled module after the fact (sounds like even after serializing the module to binary?), and that's slowing down the build. Have you tried instead generating the import-wrapping module and the export-wrapping module separately, and then dynamically linking them all in JS? That way the build would only need to generate wrapping wasm code when the import/export lists change, rather than on every build. (As a side note, if I'm reading the code correctly, you could optimize the wrappers a bit by using tail calls.)

tlively commented 7 months ago

Have you tried instead generating the import-wrapping module and the export-wrapping module separately, and then dynamically linking them all in JS?

No, because this would be significantly more complicated than running a Binaryen pass[1]. Rather than investing in a more complicated solution to solve our DevX problem, I would strongly prefer to eliminate the unnecessary complexity in the JSPI API.

[1] As examples of situations where a wrapper module would be complicated, consider that it would be an extra output file that users have to update their build systems to account for, that our thread spawning code would have to know to instantiate and link this new module on each thread, etc. We would also have to introspect on the types of the imports and exports to generate the wrappers in the first place, so we would either need to take the cost of using Binaryen anyway or create a one-off bespoke tool to generate the modules, which would be significant maintenance overhead.

But this is all beside the point because Suspenders are not useful in practice anyway!
RossTate commented 7 months ago

Have you tried adding the stage directly into Emscripten? Currently Emscripten has an -sASYNCIFY that does a whole-program analysis and then transforms every affected function to add a stack-serialization/deserialization path. As you've shown, the necessary functionality for using JSPI can be added without modifying any functions, simply by adding a global and a few wrapping functions, and doing this before/while serializing the module would address the build-speed issues. So it seems straightforward for Emscripten to add a flag to add this compilation-speed optimization. WebAssembly has a lot of precedent of imposing substantially more tooling burden in order to address forward-looking concerns.

I'll try to illustrate such a forward-looking concern. It has to do with the use of "most recent" in your proposed semantics, which seems straightforward, but historically has been found to be problematic as more features get added to a language.

As an example, let's consider adding core stack-switching, say via the design Francis presented. Suppose module A uses JSPI and a promising function calls module B, which uses core stack switching, which then calls a promising function of module C, which then calls back into B, which then switches stacks, and the new stack then calls into A, and then A calls a suspending import. What do you believe should happen, why, and how would you expect an engine to implement it?

eqrion commented 7 months ago

As an example, let's consider adding core stack-switching, say via the design Francis presented. Suppose module A uses JSPI and a promising function calls module B, which uses core stack switching, which then calls a promising function of module C, which then calls back into B, which then switches stacks, and the new stack then calls into A, and then A calls a suspending import. What do you believe should happen, why, and how would you expect an engine to implement it?

In scenarios where you're mixing different modules together, you need to have an agreed upon ABI and conventions to handle situations like this. Not just for stack switching, but for other details like memory layout, shadow stack pointers, function pointers, etc. Today and in the future I expect that to mean that you can only mix modules from a single language and toolchain in plain core wasm. The component-model actually tries to tackle this problem, but has to adopt many restrictions to make this feasible.

So if you're a single toolchain, you can eliminate this scenarios by using core stack switching for everything (there would be no benefit IIUC of mixing them). If your toolchain wanted to mix JS-PI and stack switching for some reason, you could adopt a convention to only ever have one promising function active at a time, or prohibit callbacks, or wrap callbacks in suspending functions, or some other language specific way of modeling this issue.

To rephrase it, I don't see how suspenders help toolchains emit code that they couldn't do already with linking conventions. The case with multiple modules intermixing without a single ABI and toolchain to coordinate stack switching is not practical.

RossTate commented 7 months ago

In scenarios where you're mixing different modules together, you need to have an agreed upon ABI and conventions to handle situations like this.

I think we're miscommunicating. I'm viewing modules A, B, and C more like ESM modules that each provide functionality but are developed independently. That is, they aren't coordinating on stack switching, memory layout, shadow stacks, or function pointers because they're not sharing any such internals, and my example doesn't involve any sharing of internals. My understanding is that currently websites do have such js/wasm modules compiled from different toolchains. In my example, I was thinking the ABI was common web conventions like promises. In particular, even though module B uses core stack switching internally, its outward facing ABI can still be promises. As for higher-order functions or callbacks, they are a common part of existing web conventions, most notably promises, so it seems problematic to design something for the web under the premise there will be no callbacks.

So if you're a single toolchain, you can eliminate this scenario by using core stack switching for everything (there would be no benefit IIUC of mixing them).

A browser cannot assume everything is compiled from the same toolchain (nor should it). So the question I posed about semantics and implementation still needs to be answered.


I'll try to my concern in another way. Websites combine a lot of libraries without any knowledge of how they're implemented. I don't want a situation where everything goes fine when one library implements a web API using JSPI, but things start breaking down when two libraries implement their web APIs using JSPI, or when two libraries implement their web APIs using JSPI and a third implements its web API using wasm with core stack switching. Removing suspenders would do that though because it effectively links all JSPI libraries to some same global, and when only library uses JSPI that global is effectively local, but when multiple libraries use JSPI their uses of that global can interfere.


Raising the discussion up, it sounds like people are saying that some functionality that was previously requested is no longer necessary. (Admittedly, I don't remember when the request was made; I just remember us making changes to support it. This was long ago, so probably the original use case has since been dropped.) With that in mind, I get the impression that all concerns can be addressed by a change to the design:

Have a Suspender JS class that is effectively just a mutable reference to a continuation. When you construct a Suspender, it initially points to null.

For the usage dictionary for WebAssembly.Function, rather than the promising and/or suspending keys mapping to first or last, they map to a Suspender object to use.

When a promising function gets called, a new stack is allocated and switched to, and the chosen Suspender is updated to point to the stack that the call was made from. (If it was already pointing to some continuation, i.e. not null, then trap.)

When a suspending function gets called, we suspend the current stack, we register a callback that references the suspended stack on the Promise returned by the import, and we switch to the continuation referenced by the appointed Suspender (changing the Suspender to point to null, and trapping if it was already null). When that callback gets called, we switch to the formerly suspended stack, storing the incoming stack in the Suspender (trapping if it was not already null).

So how does this fair regarding various concerns? This avoids a global being implicitly threaded through all JSPI libraries, preserving compositionality. A JSPI-unaware module can be directly wrapped, keeping tooling simple. It still permits wasm/JSPI programs to provide new promises while they have suspended computations, maintaining expressiveness. I think the only loss is that "suspended" requirement in the previous sentence, but that seems to be the functionality people are saying they do not believe we need for JSPI.

Would that revision work for everyone?

tlively commented 7 months ago

My concern is that the Suspender object is not useful in practice and unnecessarily complicates how toolchains use JSPI. Changing how Suspender works does not address that concern, and neither does experimenting with a more complicated way of using JSPI in Emscripten.

Can you explain what problem, use case, or requirement the Suspender satisfies?

You're saying that the Suspender is important to maintain modularity. You wrote,

My understanding is that currently websites do have such js/wasm modules compiled from different toolchains.

My understanding is that there are no cases in which these modules call into each other without being mediated by JS, in which case the Suspender adds no expressiveness. Here's what I wrote about this above:

The primary intended use case of the Suspender is gradual porting of modules from JS to Wasm without other modules having to be aware of the difference. I have never heard of this modular porting being done in practice. Modular porting is generally infeasible because JS and Wasm module interfaces are so different.

@eqrion is also saying that the modularity use case for Suspender is not compelling and @fgmccabe has also said recently that JS and Wasm are not interchangeable in this way.

Are you additionally saying that you think Suspender is necessary because you don't know how the semantics of JSPI could be specified without it?

eqrion commented 7 months ago

I think we're miscommunicating. I'm viewing modules A, B, and C more like ESM modules that each provide functionality but are developed independently. That is, they aren't coordinating on stack switching, memory layout, shadow stacks, or function pointers because they're not sharing any such internals, and my example doesn't involve any sharing of internals. My understanding is that currently websites do have such js/wasm modules compiled from different toolchains. In my example, I was thinking the ABI was common web conventions like promises. In particular, even though module B uses core stack switching internally, its outward facing ABI can still be promises. As for higher-order functions or callbacks, they are a common part of existing web conventions, most notably promises, so it seems problematic to design something for the web under the premise there will be no callbacks.

No toolchains I know of compose wasm modules in the way you're proposing without JS glue code wrapping the module. This JS glue code prevents any other wasm module's suspender from being active and a potential target.

Put another way, if a toolchain is using a wasm module to implement a JS/ES module interface, the 'JS ABI' implies that it's not safe to call a suspending import without knowing that one of your own promising exports is on the stack without any other ES module's frames in the way (this is where your example violates the JS-ABI). If you fail to do this, your module will crash when called by JS. If you disallow being called by JS, then your not implementing a JS/ES module interface but something different.

I'll try to my concern in another way. Websites combine a lot of libraries without any knowledge of how they're implemented. I don't want a situation where everything goes fine when one library implements a web API using JSPI, but things start breaking down when two libraries implement their web APIs using JSPI, or when two libraries implement their web APIs using JSPI and a third implements its web API using wasm with core stack switching. Removing suspenders would do that though because it effectively links all JSPI libraries to some same global, and when only library uses JSPI that global is effectively local, but when multiple libraries use JSPI their uses of that global can interfere.

Any wasm module using JS-PI to implement a JS/Web API must ensure that it's entered from it's own promising function if it calls a suspending function. If it doesn't do this, then it could be called from a JS function and the suspending function will trap. Because you must be entered from your own promising function, there is no concern of accidentally going to someone else's promising function when using the 'nearest' rule.

RossTate commented 7 months ago

@tlively and @eqrion, I am not trying to pressure you into accepting a design you have significant issues with. I offered a suggestion for how we might change the design in a way that I believe might address at least the significant issues people raised. I'm not saying that is the only option, and maybe I'm wrong and there does not exist a design that addresses everyone's significant issues simultaneously, but can we at least try to find one before pressuring anyone into accepting a design they have a significant issue with? Maybe we'll be surprised and find something better than everything mentioned so far.

eqrion commented 7 months ago

@tlively and @eqrion, I am not trying to pressure you into accepting a design you have significant issues with. I offered a suggestion for how we might change the design in a way that I believe might address at least the significant issues people raised. I'm not saying that is the only option, and maybe I'm wrong and there does not exist a design that addresses everyone's significant issues simultaneously, but can we at least try to find one before pressuring anyone into accepting a design they have a significant issue with? Maybe we'll be surprised and find something better than everything mentioned so far.

If I understand you correctly here, you're saying that you have a significant issue with a JS-PI design that doesn't include Suspender's (and instead does something like 'nearest'). My previous posts were trying to alleviate that concern (not ignore it) by showing that it won't be an issue in practice. If you disagree with those posts and think it still is an issue, please respond to them and elaborate on why.

fgmccabe commented 7 months ago

IIUC, the fundamental correctness question arises when connecting modules together: Module A uses a wrapped import of a wrapped export from Module B. In that situation, if Module B suspends, there are two options: it could suspend locally (from module B's import to its export) or globally (from module B's import to module A's export).

Originally, this was a genuine possibility since wasm-wasm imports are expected to be 'direct' without any JS intervention. And it has potentially significant performance implications involving an additional trip around the browser's event loop.

However, two changes: one gradual and one recent, affect this.

By requiring that wrapped imports use Promise.Resolve on their inputs we are effectively mandating at least one JS frame between the modules (recall that JS is monkey-patchable). This would mean that any attempt by Module B to perform a 'global' suspension should result in a trap; eliminating that potential route (there is another route bypassing the wrapping between the modules).

The more gradual change (at least from my pov), is the realization that JS is not like other programming languages. In particular, it is more-or-less always observable that a given functionality is implemented in JS. An example of a consequence of this is the requirement we have to always switch to the 'central' stack for calls into JS and for calls into so-called run-time code.

By eliminating an explicit suspender, we would effectively be declaring that there cannot be any ambiguity as to which computation is suspended: the engine already knows and any other choice will trap anyway.

fgmccabe commented 7 months ago

An addendum: The second major justification for explicit suspender comes when considering core stack switching. Here there are several areas where having explicit suspenders may be important:

tlively commented 7 months ago

If we accept that using/interoperating with JSPI always requires calling out to JS, then even those situations wouldn't need explicit suspenders:

  • When suspending an import, the app may wish to suspend internally within the application rather than immediately suspending the whole module. This is enabled with explicit suspenders.

Alternatively, this could be accomplished by calling a function indirectly via a two-part trampoline that calls out to JS then back into a JSPI-wrapped, exported Wasm helper that does the dispatch to the intended function. When a JSPI-wrapped import suspends, it will only suspend back up to this trampoline.

  • Due to some potential difficulties with ensuring no suspension of JS (with core stack switching) it may be useful to employ a kind of 'passport': an application may only use core stack switching if the code was entered via a JSPI export. This is greatly facilitated with explicit suspenders. It would also imply that JSPI would be inherently unpolyfillable.

Since Suspenders can leak and be passed around, there is no guarantee that any particular suspender corresponds to the most recent JSPI entry. Given that, how can they help speed up the check that no JS is suspended?

  • Similarly, to imports, it will likely be very useful if the core code can explicitly suspend itself to its wrapped export. This will be import, for example, when implementing coroutining schedulers that must be able to suspend to the brower's event loop. In this situation, we would not be suspending from the JS import at all.

This can be accomplished by calling out to a JSPI-wrapped import that simply returns a Promise wherever the core code wants to suspend itself.

Personally, I think it would be very reasonable for us to say that interoperating with JSPI always requires calling out to JS.

RossTate commented 7 months ago

Thanks, everyone.

I am uncomfortable with operating under the assumption that Web APIs will never be implemented entirely in wasm or wasm+JSPI. I don't think we can know that at present, and I see proposals like JSPI and https://github.com/WebAssembly/gc-js-customization that should make this quite possible; plus, there have been frequent requests for proposals making it easier to interact with JS objects from within wasm, and I don't to assume those never happen either. There is the viable option of making JSPI wrapped imports/exports be unsuspendable, like JS functions, but that does not actually alleviate all the issues.


Take my example with module A using JSPI, module B using core stack switching, and module C using JSPI (link for convenience). With suspenders, ignoring the recent changes, my example would result in the entire computation being suspended, leaving all three modules in a valid state to be resumed once A's wrapped import resumes. Now, we talked about how we don't need to support "nested" suspension in JSPI, and as such we could make wrapped exports unsuspendable. If we did so, this would trap, which would be perfectly fine. As @eqrion pointed out, this could only happen if A's wrapped import got called in some situation that JSPI isn't intended to support, and I think this is fine.

This could happen if, e.g., A gave B a callback meant to be called immediately but which B erroneously called later (a common mistake in asynchronous programming). Module A might not have dynamically protected against this because it was caught by JSPI anyways by all JS modules it tested against. And it's possible that this error can only happen in the present of the advanced control flow that JSPI and core stack swtiching provide, so none of the developers of these libraries would have been able to find the bug if testing against JS modules. But that's fine; when some webdev combines these libraries, a trap occurs, and the bug can be reported.

But that's with suspenders (with unsuspendable export wrappers). If you take suspenders away, then what's known as an "accidental capture" occurs: A's wrapped import suspends up to C's wrapped export. C's export then returns to B which then returns to A. But A thinks it's suspended on its import, putting it in an inconsistent state. Maybe this causes a trap, or maybe it just runs with garbage output. Regardless, the error is far detached from the cause, and it would likely take someone with a lot of knowledge of how all three modules are implemented and of JSPI's and core stack switching's semantics to figure out what went wrong. This, to me, as analogous to what happens when someone swallows an exception without reporting it, which is widely recognized as very bad practice. So removing suspenders, with this old semantics, creates a situation where an implicit global can cause control flow in independent JSPI modules to interfere with each other in a way that is hard to predict and even harder to debug due to accidental captures. Just like how many like that array-bounds checks prevent programs from continuing to run in corrupted states due to some bug, suspenders prevent JSPI programs from continuing to run in corrupted states due to some bug.


Forgive me for the long post, but let's now consider how using promises everywhere affects this example, because I think it will help illustrate the benefit of suspenders. First, when the call to A's promising export is made, nothing in A runs immediately. Instead, a microtask is queued to run A's computation, and a promise is immediately returned—to be resolved when A's computation completes. Sometime later, A's microtask starts, and it eventually leads to a call from B to C's promising export. Again, nothing in C runs immediately; instead, a microtask for running C is queued, and a to-be-resolved promise is returned immediately to B, which continues to do whatever it wants, including returning to A and completing A's microtask. Sometime later, C's microtask starts, and it calls back into B, which calls back into A, which calls a suspending import. What happens here depends on whether we use suspenders.

First, let's take a step back: what does this look like? To me, it looks like promising exports spawn green threads on the microtask scheduler with suspending imports yielding to that scheduler. In other words, by changing to always use promises, JSPI basically provides the "microtask OS" to WebAssembly programs. I think this is a cool mental model, but how do suspenders fit in?

Let's go back to what happens after C's promising export returns a promise to B. We're still running a microtask, and we can't move on to the newly scheduled microtask until that microtask finishes or yields. That is, either A's microtask needs to resolve to some value, or it has to call some suspending import. With suspenders (treating wrapped exports as unsuspendable), we can guarantee that the only way for that to happen (without trapping) is for B to give control back to A so that A either finishes or calls a suspending import; that is, with suspenders we guarantee that A is able to restore/establish its invariants before it finished/suspends. In the "microtask OS" analogy, suspenders are like monitors, where wrapped exports and imports with the same suspender marking the boundaries of the critical region the suspender protects. And just monitors (or locks) require direct support from the OS/hardware, so do suspenders. And just like monitors help multithreaded programs compose well, suspenders help asynchronous programs compose well.

Sorry for the long post, but hopefully that illustrates the practical benefits of suspenders. And I'd still like to hear what people think about this suggestion, which I think provides this compositionality/functionality with minimal burden on tooling and engines.

eqrion commented 7 months ago

Thanks, everyone.

I am uncomfortable with operating under the assumption that Web APIs will never be implemented entirely in wasm or wasm+JSPI. I don't think we can know that at present, and I see proposals like JSPI and https://github.com/WebAssembly/gc-js-customization that should make this quite possible; plus, there have been frequent requests for proposals making it easier to interact with JS objects from within wasm, and I don't to assume those never happen either.

I agree that we can expect and want people to be able to implement Web API's entirely in wasm. My point was that if you do this, you must assume that every public entry point (including callbacks) into your module can be called by JS or the host. So it's a bug in your code to call a suspending import without ensuring your most recent entry was a promising export.

Take my example with module A using JSPI, module B using core stack switching, and module C using JSPI (link for convenience). With suspenders, ignoring the recent changes, my example would result in the entire computation being suspended, leaving all three modules in a valid state to be resumed once A's wrapped import resumes.

That only happens if every module involved is implemented in wasm, which you cannot rely on if they are implementing a web interface. If the user swapped out module A/B for a JS equivalent implementation, it would break. The fact it incidentally would work when using only wasm modules to implement the web API's is likely to lead to developer confusion and issues.

Now, we talked about how we don't need to support "nested" suspension in JSPI, and as such we could make wrapped exports unsuspendable. If we did so, this would trap, which would be perfectly fine. As @eqrion pointed out, this could only happen if A's wrapped import got called in some situation that JSPI isn't intended to support, and I think this is fine.

This could happen if, e.g., A gave B a callback meant to be called immediately but which B erroneously called later (a common mistake in asynchronous programming). Module A might not have dynamically protected against this because it was caught by JSPI anyways by all JS modules it tested against. And it's possible that this error can only happen in the present of the advanced control flow that JSPI and core stack swtiching provide, so none of the developers of these libraries would have been able to find the bug if testing against JS modules. But that's fine; when some webdev combines these libraries, a trap occurs, and the bug can be reported.

But that's with suspenders (with unsuspendable export wrappers). If you take suspenders away, then what's known as an "accidental capture" occurs: A's wrapped import suspends up to C's wrapped export. C's export then returns to B which then returns to A. But A thinks it's suspended on its import, putting it in an inconsistent state. Maybe this causes a trap, or maybe it just runs with garbage output. Regardless, the error is far detached from the cause, and it would likely take someone with a lot of knowledge of how all three modules are implemented and of JSPI's and core stack switching's semantics to figure out what went wrong. This, to me, as analogous to what happens when someone swallows an exception without reporting it, which is widely recognized as very bad practice. So removing suspenders, with this old semantics, creates a situation where an implicit global can cause control flow in independent JSPI modules to interfere with each other in a way that is hard to predict and even harder to debug due to accidental captures. Just like how many like that array-bounds checks prevent programs from continuing to run in corrupted states due to some bug, suspenders prevent JSPI programs from continuing to run in corrupted states due to some bug.

I agree that this accidental capture due to a toolchain bug would be confusing. However, this is something that toolchains can insert guards against very easily. You could have a global flag that is cleared upon exiting your wasm-implemented Web API, set when entered via a promising export, and checked when calling a suspending export.

This would ensure your promising export is always the nearest and you won't get accidentally captured OR trap due to your either wasm or JS caller.

Yes, suspenders (with unsuspendable export wrappers) could also achieve this debugging support, but if that's the only motivation then I think that's fairly weak motivation.

RossTate commented 7 months ago

I agree that we can expect and want people to be able to implement Web API's entirely in wasm.

Cool.

My point was that if you do this, you must assume that every public entry point (including callbacks) into your module can be called by JS or the host.

Understood.

So it's a bug in your code to call a suspending import without ensuring your most recent entry was a promising export.

My point is that bugs are more complicated than this. Your code might work correctly provided someone else uses it correctly according to some contract. So the import might get called from a bad context due to a bug in someone else's code.

I agree that this accidental capture due to a toolchain bug would be confusing. However, this is something that toolchains can insert guards against very easily. You could have a global flag that is cleared upon exiting your wasm-implemented Web API, set when entered via a promising export, and checked when calling a suspending export.

This wouldn't work for the example I gave. You would have to insert dynamic checks on every import and export of the module, and—in the case of WasmGC+JS-customization—on every JS-visible method, in order to prevent accidental captures. On the other hand, the suggestion I gave above would offer similar protections by adding just a single line of JS code. (Side note, I'm noticing that the use of stack switching in my example isn't important to this part of the discussion; it just prevented the dynamics of "most recent" from using dynamic scope rather than a global chronology.)

To me, a single line of JS and a JS class with no public properties (besides a nullary constructor) seems like a small price to pay for stronger correctness, composability, and debuggability properties.

eqrion commented 7 months ago

To me, a single line of JS and a JS class with no public properties (besides a nullary constructor) seems like a small price to pay for stronger correctness, composability, and debuggability properties.

I don't think you have shown Suspender's to have any composability benefits. Your real world example with Web API's only works if the user composing these independents modules doesn't use a JS module between the wasm JS-PI module. There would be a composability benefit if we invented a new ecosystem of Web API's only implemented in pure-wasm, but that doesn't exist and isn't useful.

As for correctness, what do you mean by this? In your Web API example, you agree there is a bug in module A where it calls a suspending import without ensuring that it was called through a promising export. There is no single 'correct' thing to do in this situation, but several possible options of minimizing harm (I think what you're getting at with debuggability).

So if it only comes down to debuggability then I think it needs to come down to the cost of toolchains, VM's, and spec-authors supporting Suspenders vs. the cost for toolchains to add instrumentation (if needed) to catch these situations.

I'm open to toolchains saying that the debuggability piece is too difficult for them, but if I understand @tlively, they seem more concerned about the cost of supporting Suspender's in the toolchain than that.

RossTate commented 7 months ago

Your real world example with Web API's only works if the user composing these independents modules doesn't use a JS module between the wasm JS-PI module.

The situation you describe happens whenever a user directly composes two JSPI modules, hence this is a composability benefit.

As for correctness, what do you mean by this?

Sorry, safety would be the correct word. Suspenders help programs protect their invariants from being violated by other programs. As another example, with suspenders, a JSPI module doesn't have to worry about an import or callback causing the module to suspend unexpectedly, but with suspenders this can be done by passing a suspending function to any import/callback the module did not expect to be suspending. (I'm not sure if y'all are motivated by security, but I don't want to have experts saying "don't use JSPI because it opens a new class of attack vectors" for a proposal I'm responsible for.)

I'm open to toolchains saying that the debuggability piece is too difficult for them, but if I understand @tlively, they seem more concerned about the cost of supporting Suspender's in the toolchain than that.

I have yet to hear @tlively comment on whether my suggestion would address that concern, since now everything can be done in the generation of the JS code for instantiating the module, which has to be modified for JSPI regardless of whether one uses suspenders. Another option is, rather than adding a usage parameter to WebAssembly.Function, to add methods to Suspender for creating promising and suspending functions (which has the benefit of syntactically avoiding the possibility of a usage that is both promising and suspending).

eqrion commented 7 months ago

Your real world example with Web API's only works if the user composing these independents modules doesn't use a JS module between the wasm JS-PI module.

The situation you describe happens whenever a user directly composes two JSPI modules, hence this is a composability benefit.

Yes, that's the theoretical composability benefit I mentioned. But it won't happen in practice because there are no ecosystems where people compose only wasm modules which use JS values for data types on the boundary. People compose JS modules (which may be implemented in wasm), and suspenders don't help there.

RossTate commented 7 months ago

I'm confused. Can you explain why what you just said doesn't contradict what you said above?

I agree that we can expect and want people to be able to implement Web API's entirely in wasm.

RossTate commented 7 months ago

I agree that we can expect and want people to be able to implement Web API's entirely in wasm.

So everything seems to come down to whether people agree with this statement.

I expect that any implementer of a Web API (or web library in general) would prefer to have all interactions with its users be guaranteed to be in order. I expect any user of a Web API or web library would prefer to have all interactions with that library be guaranteed to be in order. TC39's mandate seems to expect the same.

In that case, it seems that we have chosen the wrong defaults. If the majority of systems want foreign calls to be unsuspendable, then they should by default be unsuspendable. Instead, we've gone with making them suspendable by default. This puts a substantial burden on people not using this proposal. And function calls that were once safe from this issue can become unsafe as wasm supports more functionality, even if the system at hand does not itself use that functionality. This seems to be inconsistent with wasm's pay-as-you-go design philosophy.

The main reason we want with suspendable-by-default was because "opt-in" requires suspendable functions to be annotated as such. The claim was that this would be too complicated. However, the new threads proposal is doing precisely this: https://github.com/WebAssembly/shared-everything-threads/blob/main/proposals/shared-everything-threads/Overview.md#shared-attributes. In particular, in order to spawn a function call on a new thread, the function has to be marked shared; this is directly analogous to requiring the function used to spawn a new stack is marked suspendable. So the "opt-out" choice is inconsistent with the decisions of the most closely related wasm proposal.

And the circumstances in this proposal are much easier than the new threads proposal. Whereas shared functions cannot call non-shared functions, suspendable functions can call non-suspendable functions, and we can even permit non-suspendable functions to call suspendable functions within a trap_on_suspend instr* end context, and we can have JS calls to such functions automatically insert a trap_on_suspend for convenient interop. This means a tool can opt in solely by adding suspendable to all of its function types (and, if it chooses to elide that from foreign imports, it can still get the opt-out guarantees on those calls at no cost). This is in contrast to what is required to opt out, which requires instruction-level tooling support (plus information/analysis to determine where changes are necessary) and inserts numerous run-time checks/operations.

eqrion commented 7 months ago

I'm confused. Can you explain why what you just said doesn't contradict what you said above?

I agree that we can expect and want people to be able to implement Web API's entirely in wasm.

I expect that some people will implement 'Web APIs' (i.e interfaces that communicate via only JS/web values) entirely with wasm modules. However, the ecosystems where users compose these 'Web APIs', such as NPM, will continue to be a mix of JS and Wasm implemented packages.

So JS-PI won't provide composability benefits in real world ecosystems where these interfaces are composed. There is no 'NPM' where all the packages provide JS interfaces but are only implemented with wasm modules. If there was, then JS-PI would have composability benefits in that ecosystem.

RossTate commented 7 months ago

Oh, I think I see where we're misunderstanding each other. The composability benefit I mentioned doesn't rely on all web APIs/libraries being implemented in wasm. It applies whenever more than one is implemented in wasm (and/or the application using those libraries is implemented in wasm).

I get the sense there's another point of confusion: I am not advocating for the ability to suspend computations across independently developed wasm modules. To the contrary, I think that is a bad idea, much like TC39 seems to think it is a bad idea for JS.

eqrion commented 7 months ago

I get the sense there's another point of confusion: I am not advocating for the ability to suspend computations across independently developed wasm modules. To the contrary, I think that is a bad idea, much like TC39 seems to think it is a bad idea for JS.

Okay, so if I understand you, you would therefore prefer that a suspending import can only accept a Suspender from the most recent promising export? i.e. no 'global' suspending across multiple promising exports? Or is that something you still wish to allow within a 'single independently developed wasm module'.

My understanding of the composability benefit you're suggesting was when you said:

With suspenders, ignoring the recent changes, my example would result in the entire computation being suspended, leaving all three modules in a valid state to be resumed once A's wrapped import resumes

Which would no longer be allowed.

Oh, I think I see where we're misunderstanding each other. The composability benefit I mentioned doesn't rely on all web APIs/libraries being implemented in wasm. It applies whenever more than one is implemented in wasm (and/or the application using those libraries is implemented in wasm).

Having a composability benefit that depends on an internal implementation detail of the things you're composing does not seem that useful. Users of systems like NPM won't know whether a package is pure-wasm or not, and that property may change over time. Developers of NPM packages can't control what other packages their users will compose them with.

tlively commented 7 months ago

If you consider the JSPI wrappers to be "JS frames" and therefore non-suspendable, then JSPI is completely composable without requiring a Suspender object AFAICT.

Assuming that wrapped exports and to-be-wrapped imports unconditionally return promises:

The export wrapper wraps a function t1* -> t2* and produces a function t1* -> externref, where the returned externref is a promise that resolves to t2* (or is rejected).

The import wrapper wraps a function t3* -> externref, where the returned externref is a promise that resolves to t4* (or is rejected), and produces a function t3* -> t4*.

If a function t* -> t'* is exported from a Wasm module, wrapped as a JSPI export from a Wasm/JS module, imported into another Wasm/JS module, and wrapped as a JSPI import t* -> t'* into that Wasm/JS module's inner Wasm module, then everything composes as if the inner Wasm modules were linked directly, except that there is a return to the event loop on the return boundary between the export and import wrappers. That return to the event loop is evident in the signatures of the JS/Wasm modules because they export/import t* -> Promise(t'*).

In this view of things, talking about composing raw Wasm modules that use JSPI makes no sense, because using JSPI to wrap externally-visible functions inherently makes the result a combined Wasm/JS module that has JS Promises in its interface.

RossTate commented 7 months ago

Having a composability benefit that depends on an internal implementation detail of the things you're composing does not seem that useful. Users of systems like NPM won't know whether a package is pure-wasm or not, and that property may change over time. Developers of NPM packages can't control what other packages their users will compose them with.

This why I said we should have a design for which how one library implements its promises and such does not affect how it interacts with the user or another (black box) library. That is, just like whether a function is implemented using loops or tail calls does not affect how it interacts with other functions, we should aspire to the same for JSPI and core stack switching. JS/JSPI/pure-wasm should all be interchangeable implementations of web APIs/libraries. But only opt-in designs support this. With suspenders, I can at least make sure JS/JSPI/pure-wasm are interchangeable when no attempt to suspend traps. Without suspenders, none of these are interchangeable even when no one traps.

Which would no longer be allowed.

You get better composability with either semantics for suspenders. With suspenders, JS/JSPI/pure-wasm are interchangeable provided no attempt to suspend traps. Changing the trapping semantics for attempts to suspend does not affect this property; it only affects how interchangeable things are even if attempts to suspend trap. The version that traps less actually makes at least JSPI/pure-wasm interchangeable even when attempts to suspend trap. The version that traps more makes these not interchangeable in the presence of trapping suspends—but, unlike if we removed suspenders, at least they (and JS) are interchangeable when no suspend traps.

@tlively You are assuming all exports and imports are wrapped. In many web APIs and libraries, only some exports return promises, and only some imports are expected to return promises. Plus, even currently we have JSPI users who do not operate under your assumptions. And even if we assumed all imports and exports are wrapped, there is the issue that exports of many web APIs expect higher-order inputs or return higher-order values (e.g. lambdas or objects with methods).

tlively commented 7 months ago

@tlively You are assuming all exports and imports are wrapped

No I'm not. Non-wrapped, synchronous functions can be directly imported / exported alongside wrapped, asynchronous functions just fine.

But I am assuming that non-wrapped exports do not reach wrapped imports. If a non-wrapped export does reach a wrapped import, that would trap when called from JS or potentially suspend the caller unexpectedly when called from Wasm. With Suspender, the latter case would trap as well, but since it should never happen in practice and we have other methods of debugging when it does accidentally occur, I don't consider this a good reason to have Suspenders.

This gets back to what @eqrion wrote before:

So if it only comes down to debuggability then I think it needs to come down to the cost of toolchains, VM's, and spec-authors supporting Suspenders vs. the cost for toolchains to add instrumentation (if needed) to catch these situations.

As a toolchain author, I would firmly prefer to implement optional instrumentation to catch bugs rather than have to support Suspenders in all build modes.

eqrion commented 7 months ago

Having a composability benefit that depends on an internal implementation detail of the things you're composing does not seem that useful. Users of systems like NPM won't know whether a package is pure-wasm or not, and that property may change over time. Developers of NPM packages can't control what other packages their users will compose them with.

This why I said we should have a design for which how one library implements its promises and such does not affect how it interacts with the user or another (black box) library. That is, just like whether a function is implemented using loops or tail calls does not affect how it interacts with other functions, we should aspire to the same for JSPI and core stack switching. JS/JSPI/pure-wasm should all be interchangeable implementations of web APIs/libraries. But only opt-in designs support this. With suspenders, I can at least make sure JS/JSPI/pure-wasm are interchangeable when no attempt to suspend traps. Without suspenders, none of these are interchangeable even when no one traps.

I'm not sure how this responds to what I wrote above. Do you disagree and think that suspender's in the current design provide a composability benefit the situation I describe above?