WebAssembly / js-promise-integration

JavaScript Promise Integration
Other
57 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.

RossTate commented 7 months ago

This discussion is getting very difficult to conduct because I cannot keep track of who is operating under what assumptions/expectations, making it difficult to respond to comments or answer questions. Let's take a second to see where we all stand.

@eqrion @tlively @fgmccabe Do you agree or disagree that we can expect and want people to eventually implement Web APIs and web libraries entirely in wasm?

eqrion commented 7 months ago

This discussion is getting very difficult to conduct because I cannot keep track of who is operating under what assumptions/expectations, making it difficult to respond to comments or answer questions. Let's take a second to see where we all stand.

I think it's also worth clarifying what's meant by Web API/Web library. I have been using that to refer to an independently developed, composable, component of code that only uses JS values for communication on the interface (as oppposed to wasm linear memory with a primitive ABI). I'm generally thinking of ecosystems like NPM, but there are others. A better name is probably a JS package (as JS values are the ABI), even though theoretically you could develop a JS package completely in wasm if we were to someday have esm-integration and some extra features for importing arbitrary JS builtins from the global object.

@eqrion @tlively @fgmccabe Do you agree or disagree that we can expect and want people to eventually implement Web APIs and web libraries entirely in wasm?

I expect this will happen someday, and think it's desirable for it to be possible. I do expect folks to continue implementing packages in a mix of JS/wasm or solely JS indefinitely and think that's also desirable.

RossTate commented 7 months ago

Thanks, and I agree, though I'd amend that "JS values" might include values from https://github.com/WebAssembly/gc-js-customization, i.e. wasm references with hooks for interoping with JS.

tlively commented 7 months ago

I agree with @eqrion as well.

RossTate commented 7 months ago

Thanks. Unless otherwise informed, I'll assume @fgmccabe does as well. So we're all on the same page here.

With that, I'll assume (unless otherwise informed) that we also all agree that web APIs/libraries will continue to expect in-order execution; for example, any function call made to a library or made from a library to a provided callback or object method will complete before, say, another microtask starts to run.

Now, let's flesh out what this future wasm-web-libraries world looks like a bit. One piece is already in development: https://github.com/WebAssembly/gc-js-customization will make it possible for wasm objects to masquerade as JS objects. Whatever form it takes, it will likely provide some way to, e.g., indicate that a wasm object as a method named foo that, when called with three arguments, should call some wasm function, e.g. $class_Bar_implementation_of_foo, taking that wasm object and two externref values as parameters and returning an externref value. Another piece is that is to make it easier for wasm to operate on JS values, possibly as an extension only supported by web engines. For example, rather than having to import a JS function (receiver, a, b) => receiver.foo(a,b) as $invoke_foo : [externref externref externref] -> [externref], there would be a wasm instruction (or sequence of instructions), that integrates this directly into (JS-extended) wasm, e.g. invoke_method "foo" 2: [externref externref externref] -> [externref]. With these two additional pieces of functionality, it seems straightforward for something like Kotlin/JS's interop model to compile to wasm rather than JS: using wasm implementations of v-tables for pure Kotlin interactions, using GC-JS-customization to make wasm classes extend external JS classes and interfaces, and using invoke_method and the like to access methods on dynamic objects or on values belonging to external classes and interfaces.

While I'm not saying that is a definite future, does that seem like a plausible one we want to account for? @eqrion @tlively @fgmccabe

fgmccabe commented 7 months ago

TBH, I am not sure what you are saying here. If you are implying that module A can invoke module B with & without going through the wrapping, then, yes, that is both possible and intentional. There may also be an arbitrarily complex way for doing so; as in the scenario above. In addition, it is already the case that unwrapped calls between modules may be 'direct', i.e., not involving any JS. A simple example of that is if you use funcref, or if you add a wasm function to a table. However, this is for the 'unwrapped' route.

For wrapped calls, there are three scenarios:

  1. A wrapped import calls a wrapped export. This will & must work in any version of JSPI. However, with implicit suspenders, this would be largely transparent except that there would be an extra trip to the browsers event loop (assuming it is an import to module B that suspends). With explicit suspenders, there would be an option for module B to suspend 'through' the join and suspend module A directly. However, this option is effectively eliminated by the requirement to Resolve inputs to wrapped imports.
  2. A wrapped import calls an unwrapped export. In this case, it is business as usual for the caller: it is anticipating a Promise and will suspend on it as usual.
  3. An unwrapped import calls a wrapped export. This case too is ok, the callee must obviously provide an externref (aka Promise) in order to fulfill the type signature.

As far as I can tell, all other scenarios should be reducible to one of these three. If not, what am I missing?

RossTate commented 7 months ago

TBH, I am not sure what you are saying here. If you are implying...

I am trying to identify where we all agree. Eventually we''ll probably get to some place where we disagree, but it will be a lot easier to resolve that disagreement if we know our common ground, which we can use to understand why we disagree. The discussion wasn't going well because we were all making assumptions about each other in an attempt to shortcut the discussion, which is what your post appears to be doing. Let's not jump ahead. But if you're not sure what I'm saying, rather than not sure where I'm going, then clarify what part is confusing and I'll attempt to clarify it.

I would like to know whether you agree with the three of us on the earlier topic, as well as on the one I just posed. Some things you have said in the past suggest that you might disagree with us on the earlier topic, in which case that would be useful to know (and helpful to understand why).

eqrion commented 7 months ago

Thanks. Unless otherwise informed, I'll assume @fgmccabe does as well. So we're all on the same page here.

With that, I'll assume (unless otherwise informed) that we also all agree that web APIs/libraries will continue to expect in-order execution; for example, any function call made to a library or made from a library to a provided callback or object method will complete before, say, another microtask starts to run.

Now, let's flesh out what this future wasm-web-libraries world looks like a bit. One piece is already in development: https://github.com/WebAssembly/gc-js-customization will make it possible for wasm objects to masquerade as JS objects. Whatever form it takes, it will likely provide some way to, e.g., indicate that a wasm object as a method named foo that, when called with three arguments, should call some wasm function, e.g. $class_Bar_implementation_of_foo, taking that wasm object and two externref values as parameters and returning an externref value.

Yes, I could forsee having wasm-gc objects be reflected as having prototypes, getters/setters, methods etc. It's not a given, but it's possible.

Another piece is that is to make it easier for wasm to operate on JS values, possibly as an extension only supported by web engines. For example, rather than having to import a JS function (receiver, a, b) => receiver.foo(a,b) as $invoke_foo : [externref externref externref] -> [externref], there would be a wasm instruction (or sequence of instructions), that integrates this directly into (JS-extended) wasm, e.g. invoke_method "foo" 2: [externref externref externref] -> [externref].

I do not forsee any JS specific instructions like an a invoke method as you describe. Calling methods on JS objects will continue to need to be an imported builtin of some sort. There have been discussions over time what sort of builtin it could be, and it seems possible that we could add a specific kind of JS/Wasm builtin for this that engines could recognize. It's also possible we'll do other things, or just continue requiring a JS glue function like the one you posted.

With these two additional pieces of functionality, it seems straightforward for something like Kotlin/JS's interop model to compile to wasm rather than JS: using wasm implementations of v-tables for pure Kotlin interactions, using GC-JS-customization to make wasm classes extend external JS classes and interfaces, and using invoke_method and the like to access methods on dynamic objects or on values belonging to external classes and interfaces.

While I'm not saying that is a definite future, does that seem like a plausible one we want to account for? @eqrion @tlively @fgmccabe

I don't know enough about Kotlin/JS's interop model to know if that's true or not. It seems possible?

RossTate commented 7 months ago

Thanks. I envision builtins like in the stringref proposal, but agree we do not know what will be.

Moving on since it's been a while, one more thing that would be useful to see where everyone stands.

@eqrion @tlively @fgmccabe Given that TC39 has said they want in-order call/returns to continue to be guaranteed for JS (and assuming that stance doesn't change), should developers and users of web APIs/libraries be able to continue to assume the "web ABI" ensures in-order call/returns? (For example, should they continue to be able to assume a web-ABI call completes before the next microtask starts executing, as they can now?)

tlively commented 7 months ago

Yes, I would expect so.

RossTate commented 7 months ago

Thanks. I'll take the silence to mean everyone shares the sentiment.

Then I feel like we should agree there is a problem: developers of web APIs/libraries compiling to (pure or mostly pure) WebAssembly will not be able to continue to assume in-order call/returns over the web API; they can assume that now, and they can continue to assume that if they compile to JS, but they won't be able to continue to assume this if they compile to WebAssembly.

tlively commented 7 months ago

No, because as I explained here and here, we still get in-order calls and returns as long as no Wasm library allows reaching a JSPI-wrapped import from a non-wrapped export. The only value of the Suspender object is to force a trap in that erroneous case, but there are much simpler and more useful ways for toolchains to detect and handle such errors, e.g. by wrapping exports with a trivial JS function that can do any kind of logging the toolchain desires.

I’m not interested in complicating JSPI with a mandatory feature whose only purpose is ensure we trap instead of continue execution in the face of niche toolchain bugs that are trivial to guard against by other means.

fgmccabe commented 7 months ago

To be clear: the engine does and must enforce the condition that "... no Wasm library allows reaching a JSPI-wrapped import from a non-wrapped export"; regardless of any toolchain effort.

RossTate commented 7 months ago

@fgmccabe Did you mean "cannot"? I'm trying to figure out if there's a typo or if I'm just misunderstanding what you're trying to say.

@tlively According to what we agreed on, I can write my implementation of my web API/library or usage thereof in some source language, compile it to pure wasm (without even JSPI), and have my assumption of in-order call/returns be violated. As an example, maybe my library doesn't allow reentry. To defend against bad uses, I have a global that I set every time an export gets called and I clear whenever that call finishes. If someone calls my export while that global is already set, I can throw an error. Presently, if a user complains of a bug because of this, they can send me a stack trace, and I can show in that stack trace that there are two live calls to my exports (or, more likely, they can do so themselves). But, with the extensions we're currently planning for JSPI and core stack switching, this won't be true anymore. If any of my exports accepts a web object as input, that I then invoke methods on, then my computation can be suspended without my knowledge, and possibly without my user's knowledge, and then they'll have a call to my export that throws an exception due to reentry despite there not being any reentry in the stack trace. Who knows if either of us will be able to figure out what's going on, but certainly my in-order assumption has been violated.

eqrion commented 7 months ago

Then I feel like we should agree there is a problem: developers of web APIs/libraries compiling to (pure or mostly pure) WebAssembly will not be able to continue to assume in-order call/returns over the web API; they can assume that now, and they can continue to assume that if they compile to JS, but they won't be able to continue to assume this if they compile to WebAssembly

I think that in-order call/returns is a reasonable contract for wasm modules that implement a 'web api'. I don't think that therefore implies that we cannot ever add capabilities to wasm that would allow modules to violate that contract. As WebAssembly is a target for tools, we can have a higher bar for correct usage than with other languages like JS. As far as I can tell, most of the core stack switching proposals we've been considering would allow violations of this and I do not think that is a problem.

I'm also not sure how Suspender's provide a hard guarantee here either. Could you not have two web-ABI wasm modules, A using JS-PI and B that is plain synchronous code. A wrapped export of A calls B which calls A which calls a wrapped import with the correct suspender. B's in-order assumption is violated because it is suspended and its call does not return before the next microtask begins.

fgmccabe commented 7 months ago

@RossTate The engine must enforce the condition that an unwrapped export call cannot result in a call to a wrapped import. V8 does this at the moment, and it does not need the suspender object to do so.

In general, we cannot rely on the efforts of toolchains to enforce safety conditions such as this one.

RossTate commented 7 months ago

@fgmccabe The engine cannot reasonably enforce the condition you are describing. I think you mean to say that the engine cannot trust that a wrapped import is called within the dynamic scope of a wrapped export and must dynamically check this.

I think that in-order call/returns is a reasonable contract for wasm modules that implement a 'web api'. I don't think that therefore implies that we cannot ever add capabilities to wasm that would allow modules to violate that contract. As WebAssembly is a target for tools, we can have a higher bar for correct usage than with other languages like JS. As far as I can tell, most of the core stack switching proposals we've been considering would allow violations of this and I do not think that is a problem.

@eqrion Because WebAssembly is a target for tools, all problems can be addressed by adding a suspendable qualifier to functions, just like the new threads proposal is doing with shared. The reason none of the stack-switching proposals does this is because, long ago, we decided not to do this when there was a hope that no such function qualifiers would ever be necessary, but the new threads proposal has disproven that hope. So we're now introducing a bunch of programming/tooling problems in order to avoid a tooling-only problem that we now know will have to be dealt with anyways.

fgmccabe commented 7 months ago

@RossTate Your wording is correct: in particular, V8 checks that any call to a wrapped import is in the dynamic scope of a wrapped export call. This obviously includes nested cases.

eqrion commented 7 months ago

@eqrion Because WebAssembly is a target for tools, all problems can be addressed by adding a suspendable qualifier to functions, just like the new threads proposal is doing with shared. The reason none of the stack-switching proposals does this is because, long ago, we decided not to do this when there was a hope that no such function qualifiers would ever be necessary, but the new threads proposal has disproven that hope. So we're now introducing a bunch of programming/tooling problems in order to avoid a tooling-only problem that we now know will have to be dealt with anyways.

Agreed, if we had a suspendable qualifier for functions, then we could ensure the in-order property you're talking about. However, we could then do it without suspenders too. Web ABI wasm modules could mark all their imports as non-suspendable and only operate on (ref non-suspend func). They would then need to export all their functions as non-suspendable so they could be imported. There would not need to be a suspender object.

That aside, we're currently talking about suspenders without a suspendable qualifier. So unless you believe my previous example to be mistaken, suspenders do not provide the property you are saying they do.

(Tangent about expanding function types with suspendable)

One difference in my mind between shared and suspendable is that shared is important to protect engine invariants, while suspendable appears to be about protecting source language invariants. Adding any new dimension to function types is a ton of work for engines and toolchains, and so I think there is a high burden of proof. We even previously rejected adding a dimension for 'can-tail-call' (which would be nice for engine internal ABIs) because it was a big burden for toolchains. Suspendable doesn't seem to rise above that so far.

RossTate commented 7 months ago

Alright, I feel like we've made real progress on this issue. Thanks a lot for working with me to establish a common ground we can discuss upon.

That aside, we're currently talking about suspenders without a suspendable qualifier. So unless you believe my previous example to be mistaken, suspenders do not provide the property you are saying they do.

Correct. Suspenders do not ensure in-order call/returns for all modules. They do, however, ensure suspensions go to their intended target, and they prevent unintended capture. This is similar to why exceptions use generative tags (i.e. events) rather than applicative tags (i.e. you specify the payload type and catch anything with that same payload type, akin to call_indirect or to casts in the gc proposal).

Agreed, if we had a suspendable qualifier for functions, then we could ensure the in-order property you're talking about. However, we could then do it without suspenders too.

Correct. Suspenders (like generative exceptions) are only useful in the absence of a typed solution. With types, all the composability problems go away.

Some useful historical context: it was once believed that generativity ensured composability in untyped systems using dynamic scoping. That, however, has since been disproven. Instead, it has been established that only "lexically scoped" semantics are compositional. In particular, they ensure control abstraction: separately developed modules compose the same regardless of how they implement their control constructs internally. Typed systems with lexical semantics have the even stronger property that they can maintain control invariants like in-order call/returns.

In other words, there's a hierarchy of options. The one with the strongest guarantees is the typed solution (which has an obvious lexical semantics. The untyped one with the strongest guarantees is lexical semantics. Then there's dynamic scope semantics with generative tags, which has weak internal properties, but there are some compositional properties (because it is hard for different units to interfere with each other) unless those internal properties are hit. Then there's dynamic scope semantics with applicative tags or no tags, which is what the suspenderless semantics is, and which has weak internal and compositional properties.

So, if we were to use an untyped solution due to other considerations, then suspenders move us from the bottom of this hierarchy to the top of the untyped hierarchy. In particular, JSPI, core stack switching, and asyncify are all interchangeable with suspenders. I gave a solution in this space that I believe addresses the concerns that were expressed.

One difference in my mind between shared and suspendable is that shared is important to protect engine invariants, while suspendable appears to be about protecting source language invariants.

There are many situations where the invariants of engines and embedding languages both expect in-order call/returns. And you, long ago, gave an example of such a problem with Francis's design that has yet to be addressed, but which would be eliminated by suspendable: https://github.com/WebAssembly/stack-switching/discussions/28#discussioncomment-4112179

tlively commented 7 months ago

At the subgroup discussion this morning, we were all on the same page that the sole benefit of having explicit suspenders (in any form) over not having explicit suspenders is when there is a toolchain or programmer error in how separately-produced modules are composed. Specifically, when a call to a wrapped import is unexpectedly in the dynamic context of a wrapped export, an API with explicit suspenders will produce a trap while an API without suspenders will continue execution, possibly violating application or source language invariants.

However, I do not believe introducing a trap to protect application or source language invariants is worth any additional complexity in the JSPI API, especially when:

  1. Toolchains can opt-in to achieving the same thing by trivially wrapping imports, exports, and callbacks with JS trampolines without any API changes at all.
  2. The set of errors explicit suspenders would would help with are a tiny drop in the giant bucket of potential reentrancy errors JSPI makes possible. Toolchains and programmers will have to guard against and architect around these problems anyway, and explicit suspenders do not help with that in general.
RossTate commented 6 months ago

To go into more depth, suspenders do help composed programs maintain invariants when intentionally suspending; the problem is that JSPI's opt-out design introduces so many ways for composed programs to violate these invariants via unintentional suspension that suspenders are "a tiny drop in the giant bucket" of an entirely new class of problems that JSPI introduces. In fact, the opt-out design violates a key security property of WebAssembly (elaborated below), and the discussion revealed that suspenders are unfortunately too small of a bandage over this problem.

The feedback from TC39 was that unintended suspension, i.e. violations of call-return order, would be too problematic for JS programs and ecosystem. This is concerning because WebAssembly programs rely on call-return order more than JS programs do. For example, Emscripten programs rely on call-return order to keep its programs' internally-managed shadow stack in sync with the engine's externally-managed stack. Emscripten programs rely on this property to get, for free, that every function is run with not just the correct engine-stack frame but also with the correct wasm-stack frame. This means that even if the source program does not rely on call-return order for correctness, the compiled program still does. So, given TC39's long experience with maintaining a large ecosystem of tightly composed programs, it would seem their concerns should extend to wasm.

In various security analyses of WebAssembly, experts have found ways to turn a violation of various wasm program's internal memory safety (i.e. safety not dynamically protected by the engine) into attacks, i.e. misuses of the imports granted to the wasm program (see this 2020 Usenix security paper, or this 2018 brief). But, at the same time, the experts pointed out that at least that initial violation of internal memory safety could only be caused by a violation of memory safety in the source program (due to each compiled wasm program having its own linear memory). That is, while wasm might make an error easier to exploit, it at least does not increase the size of the TCB (trusted code base—the code with privileged access). JSPI violates that property due to its opt-out design. For example, with JSPI, a different wasm program can inject a violation of internal memory safety into a wasm program compiled from a memory-safe source program: by violating call-return order, this other wasm program can cause a function in the "safe" wasm program to execute with a different function's shadow-stack frame, thereby violating internal memory safety. That means JSPI increases the size of the TCB to (recursively) include all code that calls or is called from the TCB, regardless of whether they share memory or tables or anything else. So, JSPI's opt-out design substantially increases the attack-surface of wasm programs by violating one of wasm's key security properties.

Besides security concerns, these invariant-violations would create nightmarish debugging scenarios. In particular, just like the compile-time tooling relies on call-return order, so does run-time tooling. For example, the DWARF extension is designed under the assumption that the internal shadow-stack is in sync with the engine stack. If that correspondence is broken, then the programmer will be given complete garbage. And the stack trace produced by the engine can also be pretty misleading, especially without suspenders, because chunks of the stack can be missing, having been suspended by some other program.

Note that the above issues apply to any wasm program, not just those using JSPI, so developers with no use for JSPI are forced to worry about JSPI due to its poor compositionality.


The opt-out design also causes problems for web engines due to a complication we have yet to discuss: JS-wasm interop plus trapping.

Consider either of the following observations:

  1. In any of the designs, the only way a contref/stackref/whatever "expires" is for someone to switch to it (or, in some designs, explicitly "retire" it). And in all wasm extensions, the only way for a reference's state to change is for the wasm program to cause such a change or for the wasm program to hand the reference to the embedding language and explicitly operate on it there.
  2. Many languages using first-class stacks rely on continuation-allocation-elimination: if you have an operation that spawns a continuation but does nothing but use it to return/throw from its root, then you can replace that spawn with just a function call. The fact that these are indistinguishable is why, say, Multicore OCaml can use the same try operation for continuation-allocating effects/handlers and non-continuation-allocating effects/handlers. In Francis's design, this situation is easy to identify: you can perform the transformation whenever the contref parameter doesn't escape the function the continuation is spawned with. (This is analogous to translating a recursive tail-call into a loop: how control is implemented internally should not have externally-visible effects.)

Now, using Francis's design as a running example, suppose a trap occurs within a JS embedder. Control has to be transferred to JS; more specifically, to the most recent (chronologically speaking, on the current thread) JS-to-wasm call. An opt-in design would maintain the structure necessary to find this call due to preserving the call-return order of the non-opted-in JS-to-wasm call. With Francis's design, though, the engine needs to maintain a shadow stack of JS-to-wasm calls in order to determine keep track of which stack has the most recent JS-to-wasm call. (Switching back and forth between a single stack for all JS calls would effectively encode this shadow stack as well, though by making calls to JS from stack-switching wasm more expensive.)

But let's put that aside and consider what should happen when that JS-to-wasm call is found while also thinking about observations (1) and (2). a. Suppose JS calls wasm instance A's function foo, which calls function bar, which switches to (or spawns) some stack, stores the contref somewhere, and then calls wasm instance B, which traps (with no other JS calls). The JS code needs to handle the trap, but at the same time the contref is still live, so foo's stack needs to be maintained in order to prevent violating (1). b. Suppose JS calls wasm instance A's function foo2, which performs an eliminable spawn of bar, which then switches to (or spawns) some stack, stores the contref somewhere, and then calls wasm instance B, which traps (with no other JS calls). In order to be compliant with observation (2), this should behave the same as if foo simply called bar.

In either of the above scenarios, suppose after handling the program we call back into A and A then switches to the stored contref, and suppose bar then calls console.log("reached") via some import. (Meta-observation: this pattern shows how performing calls to B on a separate stack enables A, with aid from enclosing JS, to recover from traps in B.)

In scenario (2), without optimization, we should see reached printed. So, observation (2) suggests we should still see reached printed even with optimization. Similarly, in scenario (1), observation (1) suggests we should still see reached: the contref should still point to a suspended stack frame for bar. In either scenario according to the corresponding observation, a second trap should not occur until foo returns.

The consequence of all this is that, when a trap occurs, the call frames after the JS-to-wasm call should persist even after handing the trap to the JS caller. This requires either on-demand stack splitting (which, among many other things, generally relies on the call being appropriately aligned) or spawning the wasm function on a new stack. In an opt-in design, either these potential splitting/spawning spots are explicitly marked, achieving pay-as-you-go functionality, or the call-return order guarantee ensures the following wasm frames are no longer reachable and can be unwound. In an opt-out design, every JS-to-wasm call has to spawn a new stack or be set up to support splitting.


In short, opt-out designs cause a new class of many problems for developers, users, tooling, and engines. I have identified a few of them, but this is a space we do not understand well, so there are likely more. The discussion above helped me realize that suspenders are an insufficient patch over these problems.

tlively commented 6 months ago

Yep, JSPI does create a large new universe of potential problems that toolchains will have to guard against and it also constrains the space of stack switching designs. That being said, it’s also a solution for the large universe of existing problems around calling async JS/Web APIs.

I’m glad we’re on the same page with respect to the usefulness of explicit suspenders. Perhaps we can go ahead and remove suspenders from the design and resolve this issue?

fgmccabe commented 6 months ago

(a) I need more time to fully digest some of your thoughts. (b) It is 'well known' that WebAssembly's security model does not cover any attacks against the application or user. It is strictly limited to threats against the embedder. Personally, I find this to be deplorable; however, fixing it goes way beyond any discussion around JSPI or stack switching more generally.

RossTate commented 6 months ago

@fgmccabe

(a) Thanks for taking the time! (and for letting me know to wait.) I made an effort to compress, but it's still a lot.

(b) WebAssembly has a stronger security model than that. Though protecting the embedder is the highest priority, WebAssembly's design also places emphasis on isolation and encapsulation. WebAssembly modules have to explicitly opt-in to exporting functions (despite the fact that this could easily be done in JS), and WebAssembly modules have to explicitly request access to common functions (despite them being in the global namespace for JavaScript). WebAssembly modules can get private memories, rather than indices into some common WebAssembly memory (despite the fact that having a global memory would facilitate sharing large data across WebAssembly programs). This isn't necessary for composition, but it's very useful for security—keeping a bug in WebAssembly program from corrupting others. Here's some text from the Security section of the original WebAssembly paper to that effect:

At worst, a buggy or exploited WebAssembly program can make a mess of the data in its own memory.

That sentence is emphasized by the Usenix security paper as a key security property of WebAssembly. If you look around for best practices for using WebAssembly securely, you will regularly see recommendations to use this isolation in order to limit the abilities of buggy/malicious WebAssembly code. (For example, it's the top piece of advice in this first article Google provides when you search for "webassembly security best practices".)

So WebAssembly's design exhibits a lot of isolation-by-default, and current security practices rely on WebAssembly's isolation, which opt-out designs violate. (Specifically, it violates control isolation, which then can be used to violate the protections intended to be provided by memory isolation.) I'm not saying this is clear-cut, but WebAssembly's design would certainly look very different (and have much worse security/maintainability) if it had instead adopted C/C++'s exposed-by-default design philosophy to memory and control.

(No need to argue over this; I just thought you might find those observations useful.)

fgmccabe commented 6 months ago

@RossTate Wrt maintaining call return order for wasm.

  1. We are not at all in the same expectation domain for wasm vs JavaScript. So, we should not expect the concerns to be the same.
  2. It is definitely true that Emcripten uses the call return order to maintain structures such as the shadow stack for C++ variables. Currently, Emscripten does not support reentrant use of JSPI; something which should be fixed IMO. A fix for that will likely involve some additional logic to support multiple shadow stacks and to switch between them. This is outside our concern, IMO; however, it definitely should be addressed by Emscripten.
  3. Even if/when Emscripten does support reentrancy, the C application may not. So, there will be some responsibility on the C programmer to correctly 'prepare' their application to use JSPI - especially in reentrant situations.

As to whether No. 3 is our concern or not: I am in two minds: (a) from a technical perspective, I don't think we can do much to automatically fix it, and any such fix seems well beyond the scope of JSPI, (b) I do believe that it is worth providing a 'technical note' suggesting to the developer what the potential pitfalls are and how they might work around them. However, I don't think this should block JSPI.

RossTate commented 6 months ago
  1. My understanding of the core concern TC39 had about suspension was that (most) JS programs and tooling expect standard call-return order. The same is true of (most) WebAssembly programs and tooling. So, while there are differences in expectations between these languages, it seems like the key expectation at hand is shared by both. But I would be interested to hear of a particular expectation-difference you have in mind. The only expectation-difference to has come up that seems pertinent is that JS programmers expect to be able to directly compose, securely isolate, and debug-in-the-presence-of independently developed JS programs whereas currently WebAssembly programmers do not expect the same for independently developed WebAssembly programs; one of the issues at hand is whether we want to operate under current expectations due to wasm's limitations or aspire towards the standards other languages and language runtimes strive for.

I am confused why you brought up 2 and 3; could you explain? They do not seem to be on topic to me: the issues I raised were things like how JSPI would make memory-safe programs unsafe (regardless of whether they use JSPI or not), whereas 2 and 3 are about how to update compilers to use JSPI effectively (which I do not believe I made any statement about).

fgmccabe commented 6 months ago

Fundamentally, it seems up to the toolchain (Emscripten in this case) to ensure correct code. As far as "making memory-safe programs unsafe", I still don't see the argument that you are making. However, it is pretty important.

RossTate commented 6 months ago

So let's say that a toolchain using a shadow stack does so correctly if it always ensures its current shadow-stack frame corresponds to the currently executing wasm function generated by the toolchain. At present, a toolchain can do so pretty easily by just incrementing and decrementing the shadow-stack pointer as functions start and end just as one would natively increment the stack pointer as functions start and end. Note, though, that this simplicity relies on standard call-return order. With an opt-in design, that same toolchain is still correct; it hasn't opted in to using JSPI or stack switching, and the opt-in design guarantees standard call-return order for anyone who hasn't opted in to non-standard call-return order. But with an opt-out design, that toolchain is now incorrect; its functionality hasn't changed, but its environment has changed in a way that can violate its invariants. As a consequence, it becomes susceptible to the issues outlined in the Usenix security paper, even when the source program is memory safe and makes no use of stack-switching or JSPI.

fgmccabe commented 6 months ago

Except that, assuming the toolchain is aware of JSPI (not an unreasonable assumption), it can combine the obvious integration of JSPI (e.g., saving and restoring the Suspender object (assuming we keep it)) with a more nuanced management of the Emscripten (say) shadow stack. Currently, Emscripten is not reentrant, even though JSPI allows it. This is implemented by inserted JS glue code that blocks it. I think that think kind of extended support is both to be expected and reasonable. Are you saying that, in the presence of JSPI, it is not possible to construct such 'guard rail' code? Or, that you think it is not reasonable to expect the need for it?

RossTate commented 6 months ago

A prevailing design principle for WebAssembly has been pay-as-you-go: only users of the extension should suffer the costs of the extension.

Opt-in is clearly pay-as-you-go. We can make the payment quite low, to the point where one can retrofit existing tooling by only making a pass through its types section to add "suspendable" to each function (without touching the code section), and the engine just needs to check that calls to "suspendable" functions are made within "suspendable" contexts. This is small compared to the tooling cost of asyncify to add similar functionality, or to the tooling+engine cost to add support for GC; and we know another major extension will be requiring similar kinds of costs anyways.

Opt-out, on the other hand, is not at all pay-as-you-go. With a pay-as-you-go design principle, it is unreasonable to expect tooling not using a feature to undergo more significant changes than tooling actually using the feature. There's also the (currently unknown) runtime costs associated with those changes that, again, non-users are the ones forced to pay.

Then there's the issue of actually implementing "guard rails". Firstly, having the correctness of a compiler rely on inserting sufficient guard rails that it has never done before is not exactly a great premise. Secondly, adding sufficient guard rails is more than just "add JS code around each of your imports and exports". That only works for first-order boundaries. But, as we've discussed, web libraries are generally built around higher-order boundaries. This means either the entire compilation process has to be modified to guard all higher-order calls, or you have to use higher-order guards that dynamically allocate wrappers around higher-order objects that guard appropriately. The research on higher-order contracts has found that both approaches have downsides; the former requires extensive changes, increases code size, and slows down "good" higher-order calls; the latter incurs performance costs that have been measured to get quite high, and it introduces problems with object identity (due to the wrapper having a different identity). Things get even messier when you consider programs (especially higher-order ones) that want to support internal/intended suspensions but guard against external suspensions, since the guards against external/unwanted suspensions can block internal/intended suspensions if inserted too coarsely.

fgmccabe commented 6 months ago
  1. I agree that opt-in is strictly better than opt-out. However, reopening this discussion now is not great timing, and there has to be a compelling reason for that.
  2. The re-usability story for WebAssembly is not the same as for JavaScript (or the JVM). In particular, wasm modules are pretty weak. Although, as you point out, it is possible to mark memories as private, and only some functions are exported; this is not true for imports and not really true for exports (external users of a module can 'fish out' functions from tables).
  3. A better basis for interoperability (and eco-system support) is, imo, the component model. This has been engineered to be composable and reusable. And is fundamentally oriented around opt-in across component boundaries. In particular, the kind of loose re-use you are referring to is not possible in the component model. (There are some performance issues too, which has caused consternation.)
  4. An author is expected to be fully aware of all the code within a component. This includes being able to anticipate possible interactions from suspending code. It also includes other forms of side-effects such as changing memories and tables. So, again IMO, depending on assurances from toolchains is legitimate within a component -- so long as we cannot compromise the engine itself.
RossTate commented 6 months ago
  1. The compelling reason is that it introduces many major issues, outlined above, that do not seem fixable without breaking backwards compatibility with this proposal. The proposal is breaking a fundamental boundary, and it is hard to restore those once broken. (I do wish I had understood all these issues before, but I do also remember the team acknowledging that the opt-out path was the high-risk path due to our limited understanding of the space. Suspenders were an attempt to address these issues, but we have now discovered they are insufficient; the new crack opt-out creates is simply too big to patch over.)
  2. External users cannot fish out functions from tables. If a module instance exports a funcref table and places a reference to its function, then it can be fished out, but both of those are explicit steps of exposure. WebAssembly modules are not weak; they are strongly encapsulated.
  3. The component model is geared towards primarily coarse-grained and primarily first-order interop, although utilizing adapters to make fine-grained conversions between data representations. This will work for some web APIs, but many web APIs involve fine-grained higher-order interaction. Systems like Kotlin/JS+wasm (and, I believe, J2CL) already support fine-grained higher-order interop and seem unlikely to adopt the component model.
  4. Authors of Kotlin/JS+wasm code anticipate zero interactions from external suspending code because it is not currently possible; I would expect the same is true of most uses of interop models or FFIs. I am not sure how these languages would even be modified to enable authors to account for such interactions.
tlively commented 6 months ago

I think it would be good to remove explicit suspenders from the proposal and resolve this issue, then file a separate issue to discuss the trade offs explicit opt-in would have had. I have some thoughts on the topic, but I'd like to see concrete progress on the suspender issue before discussing other things.

RossTate commented 6 months ago

During this discussion I came across some more potential benefits of suspenders. I'm not saying that they're a must, but there are some much less convoluted situations where they would be useful. But it doesn't seem to make sense to discuss that until after opt-in versus opt-out is discussed since suspenders do not affect that discussion but that discussion affects suspenders. In fact, having two different discussions at the same time that cover a lot of the same topics seems likely to cause a lot of trouble. That's why I hadn't brought those new benefits up; there's already too much to discuss at a time. But starting a new issue (or discussion?) focused on opt-in versus opt-out and revisiting this one afterwards seems sensible; I'm interested to hear your thoughts.

tlively commented 6 months ago

My understanding is that there is no appetite among implementers or toolchain developers to even explore an opt-in solution, so I do not want to spend time discussing it hypothetically until we have made concrete progress on finalizing the API we will ship. We should proceed assuming we will not switch to an opt-in design. If we cannot all agree to that, then we should escalate this to a consensus poll to make progress.

RossTate commented 6 months ago

If you could elaborate on why adding "suspendable" to all function types is deemed so onerous for tooling, it would be very helpful.

fgmccabe commented 6 months ago

As far as opt-in vs opt-out:

  1. I do not understand yet why we might need opt-in: @RossTate I believe you need to make the case in a clear way (i.e., it is not yet clear to me, or others I suspect).
  2. Assuming that the case can be made, it is clearly something that should be discussed (and voted on) at the full CG level.
RossTate commented 6 months ago

Here is a C program:

extern void launch_missiles();
extern void checkin();

void (*mutable_global)() = &launch_missiles;
void benign() {}
void foo_internal(void (**func_ptr_ptr)()) {
    checkin();
}
/*export*/ void foo() {
    void (*func_ptr)() = &benign;
    foo_internal(&func_ptr)
    (*func_ptr)();
}
void bar_internal(int *int_ptr) {
    checkin();
}
/*export*/ int bar(int input) {
    bar_internal(&input);
    return input;
}

Supposing only foo and bar are exported, it should be obvious that this program will never call launch_missiles. (A more realistic example would only call launch_missiles under certain conditions, but I decided to make this example as simple as possible in order to maximize understandability.) This is true even if there's reentrancy, e.g. checkin can call foo or bar and it still won't ever launch missiles.

It should also be clear that we can compile this program to a WebAssembly module that imports launch_missiles: [] -> [] and checkin: [] -> [] and exports foo: [] -> [] and bar: [i32] -> [i32]. In order to support function pointers, this module can use a non-exported function table; any function whose address is taken is placed somewhere within this table, and its index in that table is its address. In order to support pointers to local variables, this module can use a shadow stack in non-exported linear memory that it stores an addressable local variable's value in. As with the source program, no matter how this module is instantiated and no matter how its exports are invoked, it will never call launch_missiles. And, to clarify, its imports and exports are intended to be synchronous; there is no need for JSPI here. In particular, it supports reentrancy as is, even with a single global shadow stack.

The problem I'm illustrating is that other programs using JSPI (or core stack switching) can cause this program to launch_missiles:

  1. A JSPI-promising function of some module calls foo.
  2. foo calls foo_internal, putting the address of benign on the shadow stack, and then calls checkin.
  3. The imported function checkin eventually calls some JSPI-suspending import of some module (without suspenders, this module does not even have to be related to the one that called foo), which returns promise P1, thereby suspending the computation with foo and foo_internal on the suspended stack (and with the address of benign still on the shadow stack represented in linear memory).
  4. A JSPI-promising function of some module calls bar with a value that happens to be the address of launch_missiles.
  5. bar calls bar_internal, putting the address of launch_missiles on the shadow stack, and then calls checkin.
  6. The imported function checkin eventually calls some JSPI-suspending import of some module (without suspenders, this module does not even have to be related to the one that called bar), which returns promise P2, thereby suspending the computation with bar and bar_internal on the suspended stack (and with the address of launch_missiles still at the leaf of the shadow stack represented in linear memory).
  7. Promise P1 resolves, and eventually its call to checkin returns, which then causes foo_internal to return. foo then uses the current leaf of the shadow stack to get the value of func_ptr and then uses this index to perform a call_indirect. This index, though, is now launch_missiles because foo is executing with a shadow-frame that should have been for a bar wasm-frame, but which isn't due to JSPI (or opt-out core stack switching) violating a standard invariant.

Does that make sense?

fgmccabe commented 6 months ago

If I may summarize this, you are saying that calls to reentrant JSPI calls can mess with the shadow stack?

fgmccabe commented 6 months ago

As a followup; I think that an important question is 'what kind of execution guarantee do we offer current wasm programmers about the future'. I would say that, currently, its a weak guarantee: the instructions you use today will still be available tomorrow. We don't offer the same guarantee that the JVM offers, for example. (As a side-note, I believe that the JVM's guarantee is super important; but it probably implies you can't run C++ [JVM has no equivalent of linear memory]) Ross's example is interesting because it uses linear memory, even though the source programmer had no idea that it does: because the programming model for wasm is not 'strong enough'. If, for example, we could support stack references directly in wasm, then the example fails (i.e.. would never get corrupted).

RossTate commented 6 months ago

Cool, it seems the example was at least somewhat illustrative!

If I may summarize this, you are saying that calls to reentrant JSPI calls can mess with the shadow stack?

Yes, though reentrancy of JSPI isn't so important. (I can change the example to use two different imports, foocheckin and barcheckin, so that no JSPI module simultaneously has frames on multiple stacks; only this non-JSPI module would.) And JSPI itself isn't so important; I can recreate this example with every proposed stack-switching design because they all use an opt-out design. (The only interesting difference is that only with suspenderless JSPI can I create this example without some sort of sharing between the module implementing checkin and the module(s) calling foo or bar.)

In other words, "other modules using opt-out JSPI or stack switching can mess with one's own private shadow stack".

As a followup; I think that an important question is 'what kind of execution guarantee do we offer current wasm programmers about the future'.

I don't think this is about guarantees about the future. It's about what WebAssembly programs need for correctness, which consequently implies what tools will need for correctness and/or what programmers writing programs to be compiled to WebAssembly need to take into account to ensure correctness. So how will the programmer need to change that program, or how will the tooling and compiled runtime need to change, in order to ensure correctness even when others use JSPI or opt-out stack switching? And why is it reasonable to expect them to do so when WebAssembly's primary setting is the web ecosystem, which already ensures call-return order?

I believe that the JVM's guarantee is super important; but it probably implies you can't run C++ [JVM has no equivalent of linear memory]

The JVM has int[], so it's easy to implement linear memory in it. But I think that misses the key difference. WebAssembly is explicitly designed around the expectation that a WebAssembly program includes both the source program and its language's runtime; manually implemented shadow stacks in linear memory and manually implemented v-tables in WasmGC are key examples of this. But language runtimes rely heavily on having exclusive access to its own memory and its own control, and WebAssembly (so far) supports this need by providing a memory it has exclusive access to memory and, whenever an export is called, a thread of control it has exclusive access to until it returns it or calls some import. So WebAssembly provides an isolated virtual environment that recreates the sort of isolated (virtual) environment an operating system provides a process.

What my example illustrates is that the correctness of this model relies not just on the memory isolation WebAssembly provides but also on the control isolation WebAssembly provides. Shadow stacks are an example of this, but they are not alone. Another example that comes to mind is using a global to maintain the current dynamic scope, which I understand is the plan for how to support OCaml's dynamically scoped effect handlers with your core stack-switching design. Another example that comes to mind is implementing a conservative Boehm garbage collector in wasm (using a root-scanning extension), which can only work correctly if it knows all live stacks with frames from the relevant module, which cannot be done if an arbitrary import suspended a stack (without its knowledge).

So, I believe WebAssembly's approach to supporting multiple languages by having them ship their runtime with their programs suggests maintaining call-return order.

fgmccabe commented 6 months ago

A head's up: I have scheduled the meeting of the stacks subgroup on 2/12 to put a final resolution to this matter.

RossTate commented 6 months ago

Could you swap that with my upcoming presentation date? I'd like to have this resolved before that presentation.

fgmccabe commented 6 months ago

I can do that.

fgmccabe commented 6 months ago

Done

fgmccabe commented 5 months ago

Based on a vote at the stack subgroup meeting on 1-29-24, the consensus is that the Suspender object will be dropped from the API. This will affect the API in a number of ways; hopefully the implementation of the change will be reasonably prompt.

vouillon commented 5 months ago
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.

Actually, we are always suspending to the most recent JS frame. I don't think we could even make use of this extra expressivity as en optimization, since the semantics of performing an effect is to suspend just the current thread of execution to switch to the effect handler ; we never suspend several threads of execution at once.