Repository navigation
JS interop: no-frills approach #279
Description
This expands on @tlively 's idea here: #275 (comment)
I've been thinking about the situation created by multiple (semi-cooperative?) modules that use semantically-distinct types which get canonicalized per the isorecursive type system rules, but for which the involved modules would like to set up different JS interop behavior.
For a concrete example, suppose we have:
; module A
(type $point (struct (field $x f64) (field $y f64)))
; module B
(type $size (struct (field $width f64) (field $height f64)))If we imagine an "idiomatic" JS interop scheme, then that would likely mean some way of setting up named properties (so JS code can use my_point.x and my_size.width) and/or prototypes (RTT_for_$size.prototype = { area() { return this.width * this.height; } } etc). It is easy to see that this easily leads to collisions. In the "benign" case, we may simply end up with an object whose .x and .width properties are aliasing each other; but trouble arises when both types use the same name for different fields. If prototypes are assigned whole-sale (i.e. .prototype = { area() {...} }), they'll just clobber each other ("last one wins"); if they are assembled piecemeal (i.e. .prototype.area = function() { ... }), we'll again have problems if individual names clash; if we permit divergence between JS-exposed prototypes and Wasm-level typing (as some design explorations have suggested, e.g. by storing prototypes on RTTs, which would make these RTTs distinguishable in JS but indistinguishable in Wasm), then it seems unavoidable that this divergence would be observable from Wasm-module-embedding code and lead to some pretty weird behavior (such as prototypes "magically" changing or getting lost).
Whether the JS annotations are set up imperatively or declaratively doesn't make much of a difference for this problem.
One solution to these concerns is to stick our heads in the sand and hope that these cases won't happen much in practice. The primary mitigating factor is likely the fact that (at least with Java-like source languages) it is exceedingly unlikely for two semantically-different classes to have identically-typed vtables, and that alone prevents their isorecursive canonicalization. So maybe it's fine in practice.
Thomas' idea is the other solution. By not exposing named fields or prototypes and instead fully relying on exported functions, we can avoid the whole problem. To be specific: the Wasm modules would, with some module-producer-defined naming convention, export free functions that act as accessors, e.g.:
; module A
(func (export "$point_get_x") (param $p (ref $point)) (result f64) (struct.get (local.get 0) $point.$x))
; module B
(func (export "$size_get_width") (param $s (ref $size)) (result f64) (struct.get (local.get 0) $size.$width))Both functions, in this case, will compile to the same code, but that's not a problem. If the two modules want to export identically-named functions with different behavior, then that's also not a problem.
Obviously, JS code would have to write moduleA.exports.$point_get_x(my_point) instead of my_point.x, which is undoubtedly not idiomatic in JS. The verbosity can be mitigated a bit with shorthands, like let get_x = moduleA.exports.$point_get_x; get_x(my_point);.
From a higher-level point of view, this approach means that we reconcile the differences in object model / type system between Wasm and other languages (such as JS) by exposing Wasm objects "as they are": when interacting with them, code has to adapt to Wasm conventions and mental models. This is in contrast to alternative designs where Wasm objects would put in an effort to pretend to act like some other language's objects, which is fraught with peril because Wasm cannot possibly hope to do a faithful job of that in all conceivable other languages.
In summary, this "no-frills" approach would have these benefits:
- avoids issues caused by clashing names due to isorecursively canonicalized types
- avoids issues of imperfectly simulating foreign-language objects, by not even pretending to try
- allows Binaryen to reorder/eliminate struct fields without breaking the module's public interface (the reason Thomas mentioned originally)
At the cost of a drawback:
- JavaScript code will be less idiomatic.
Performance should be similar in the limit: engines have to work a little harder to inline such accessors, but there are no fundamental obstacles to doing that.
Thoughts?
Activity
jakobkummerow commentedon Feb 22, 2022
Forgot to mention: there is a third solution, and that is to make information about exposed fields (and/or methods) part of the canonicalization algorithm. Details TBD, but what it would boil down to is that one way or another we'd have to associate the information how fields are exposed with the Wasm types such that the type system "can see it", and then
(type $point (field (expose-as "x") f64)and(type $size (field expose-as "width"))would be considered distinct by the canonicalization system. In a way, this is a variant of the "head in the sand" strategy where we add a formal constraint to ensure that the strategy is safe. This would mean that intentional canonicalization would gain a dependency on matching names -- is that acceptable?rossberg commentedon Feb 22, 2022
Personally, I would not mind the no-frills approach at all. It certainly is cleanest, and avoids the danger of building in ad-hoc solutions and whose practical value is dubious for anything but toy examples.
That said, the RTT design is supposed to provide an easy solution to the example scenario. In case that solution is not clear to everybody reading this, let me spell it out.
The modules define imports for the respective RTTs, which they consistently use to create structs:
The JS API has a class
WebAssembly.Struct, for example, whose constructor takes a type descriptor as well as an optional prototype. The glue code would do something like:The field names in the struct descriptor become accessors on the observable struct object, the prototype is either the one given with the constructor (as for
point_t) or some default (as forsize_t).This further assumes the API shorthand that an instance of type WA.Struct can be dually used as an RTT type in a global descriptor (and others) and as a value of that type, whose default initialisation is itself.
With this, the following code would work as expected:
Furthermore, there is no problem with merging the modules in a bundler, since the RTTs are distinct imports.
To be sure, this does not prevent the modules from mixing up or returning the wrong kind of struct objects if they're buggy (the Wasm type system does not enforce that), but that's the norm in JavaScript.
kripken commentedon Feb 22, 2022
I realized that the no-frills approach is what various existing tools do today in the wasm MVP space. For example Emscripten does this for binding C++ to JS in its WebIDL binder tool. Some example code:
The following bindings code is emitted in cpp:
And the emitted JS looks like this:
We have many years' experience of this approach and it works well, so it sounds good to me. With wasm GC it would work even better as the exported getters/setters would pass in typed references and not just
i32pointers (and also cycles could be collected).tlively commentedon Feb 22, 2022
We could perhaps make the no-frills approach more idiomatic by adding one small frill: interpreting
anyreffields at index zero as prototypes. Instead of importing RTT values as in @rossberg's sketch, the module would instead importanyrefprototype objects as globals and pass them explicitly tostruct.neworstruct.setjust like any other value. After instantiation, the exported getters and setters could be installed on the objects that had been supplied as the imported prototypes, similar to how the getter is installed on the prototype in @kripken's example.I know @jakobkummerow has expressed concerns about the expense of imperatively setting up all the prototypes like this, but my guess is that only a small minority of types would be meant to be exposed to JS, so only relatively few prototypes would be initialized. The fact that existing binding tools already do something similar suggests that this wouldn't be a problem.
rossberg commentedon Feb 22, 2022
I don't know, that would closely entangle the way Wasm code has to define its types with the JavaScript side. Essentially, you'd have to generate JS-specific Wasm, and it only works for JS. It's much preferable to have a mechanism that separates embedding concerns through some suitable abstraction.
(It also isn't clear how this would interact with subtyping or an efficient representation of structs inside Wasm.)
takikawa commentedon Feb 25, 2022
I do see the benefits of a no-frills approach in that it doesn't require much change in how the JS API already works. But I also worry that we will eventually want to allow more idiomatic interaction in JS. Will it be possible to set up a no-frills JS API such that it could be extended in the future?
For example, even if Wasm objects are exported like this and fields should only be accessible by exported functions, the JS API still needs to specify how they appear to JS. It'd be nice if this could be done in a way that's relatively compatible with future changes (is it sufficient to have them be sealed empty objects with a null and immutable prototype? Spec-wise, it'd presumably have an internal slot to track the underlying wasm struct)
And for post-MVP GC, eventually Wasm structs might be share-able across threads. Keeping that scenario in mind, it would be good to ensure structs could still be compatible somehow with being reflected as JS shared structs in the future so they can share the underlying concurrency technology.
In the past, it seems like Wasm's design has generally evolved towards providing the JS API with most of the same abilities as Wasm (eventually). With BigInt integration, JS can provide inhabitants for all of the core types. And with the type reflection proposal's
WebAssembly.Functionconstructor, JS can supply functions that can inhabit thefuncreftype. Both of these examples were filling in gaps that already existed in the design, and it'd be nice to ensure an extension could fill in the structs & arrays gap in a similar fashion, if the API starts out minimal at first.jakobkummerow commentedon Feb 28, 2022
I realized that the "no-frills" approach even allows idiomatic JS syntax with some (possibly toolchain-generated) wrapper code, at the cost of some indirection:
So even for idiomatic JavaScript, the only thing we really need is an
externrefequivalent in the other direction, i.e. exposing Wasm objects as opaque references to JavaScript.An open question is whether this would be fast enough. I suppose engines could inline those accessors, which should allow a near-optimal performance ceiling. Supporting that is extra work that engine implementers have to do; that said, supporting any form of direct
wasm_size.widthaccess may well be even more effort.The example above kind of assumes that the JavaScript side controls the creation and lifetime of objects. If we assume that the Wasm side controls object creation, and exposes (some of) them to JavaScript, then things get slightly more complicated but still manageable: in that case, to avoid repeated wrapper object creation, the Wasm object would get an
externref-typed field to refer to the JS wrapper; this wrapper would be created on demand and then stored in that field. While that does require changing the Wasm-side object type definitions in order to support interop, I think it's still a change that can be done by toolchains, so (1) it doesn't burden developers and (2) toolchains could simply stop doing that if we ever come up with another solution in the future.dcodeIO commentedon Feb 28, 2022
Wondering how the
externreftyped field referring back to the JS wrapper would look and behave. Is the following about what you'd imagine?jakobkummerow commentedon Feb 28, 2022
Something like that, yeah, modulo details. One key point: the Wasm type for
Elementwould hold its own wrapper (for reusability/encapsulation), soBoxwould only need a singleelementfield. And then you can choose whether you preferObject.createor an "overloaded" constructor; the latter would get more convenient than the sketch below if we specified thattypeofreturns"wasmobject"(or similar) for Wasm objects.Or somesuch, but at least that's the high-level concept :-)
If
Rects can be created by Wasm as well (and exposed to JS afterwards), they'd similarly overload their constructor (...typeof top_or_wasm...), and have afromWasmmethod, to be used by whichever function retrieves them from the Wasm module.If the likelihood of being exposed to JS is high, the Wasm-side constructor function could call out to JS to always initialize the
wrapperfield, and then the JS-sidefromWasm()could be an unconditional one-liner.carlopi commentedon Mar 1, 2022
Yes, tooling can do sort of anything, but still it might worth asking what should be the basic capabilities being present to support that efficiently.
I would think that solution that would require adding a round-trip (represented as actual references) just for the sake of making a nicer external available interface should be avoided if possible.
One possibility could be having WasmGCObject (or whatever the JS-reference to a WasmGC-backed object superclass is) have a prototype like:
On top of this you can then easily add back names or more meaningful accessors externally from the module.
This has as a a couple of advantages:
The only problem that I see with approach with explicit accessors is if you want to expose only a subset of members, but that is still problematic basically in any case (since we are saying that accessors are a property of the type, but the same type can be used both in context where it's internal or part of the interface.
dcodeIO commentedon Mar 1, 2022
Regarding the wrappers, I am seeing a few aspects that may eventually become relevant:
One is that there naturally is a break even point between generating static glue code (lots of repetition but different names, which is probably fine for modules with a small interface) respectively only attaching metadata and utilizing a shared library of sorts to generate the necessary glue from it (fixed size library + compact descriptions, eventually becoming smaller / more easily wieldable). Also, generated static glue code is tied to the respective embedding, while metadata is reusable on a per-embedding or per-language basis, and I am wondering how long the former will be considered practical.
Another is that in a more connected world, each language interfacing with a GC module may need its own wrapper, say for example where GC objects are both accessed by JS (using a wrapper like above) and a separate Wasm module (needing another wrapper). There, a single
externreffield assuming one wrapper may not be sufficient, likely again leading to standardizing metadata.Furthermore, the need for wrappers forms a kind of barrier where metadata must be known anyhow to reasoanbly optimize a module graph, think meta-dce, and it would likely be more practical if toolchains like Binaryen could reason about what's needed and what's not without, for example, having to parse glue code in various languages to find out.
Hard to say how much of this is relevant in an MVP, of course, but I'd assume that these aspects will become relevant rather quickly once there is a usable MVP, ultimately leading down the metadata path for its many benefits.
jakobkummerow commentedon Mar 1, 2022
@carlopi and @dcodeIO :
To clarify: the no-frills approach requires no round trips and no glue code. Exported accessor functions (plus opaque references) enable full expressiveness. The approach is therefore language/embedder independent.
My post about the possibility of creating nicely idiomatic wrappers for those who want them was just pointing out that this is possible. Yes, it comes at a cost. But it's opt-in, it's not a burden placed on everyone.
If you want to explore alternative designs (whether that's index-based access to struct fields, or metadata-based approaches), please kindly file new issues, so that we can keep the respective discussions focused on one thing at a time.
Relying on exported functions (as accessors) is precisely what allows Binaryen to reason about what's needed. (This was, in fact, the original reason why @tlively suggested this approach.)
Nothing in the no-frills approach suggests that anyone should have a reason to parse glue code in any language.
tlively commentedon Mar 2, 2022
Partners I've talked to including j2wasm, sheets, and Dart have all indicated that having a minimal JS API in the MVP would be fine for them. At the same time, there's clearly interest in continuing to flesh out ideas of how a richer and more ergonomic JS API could work. How would folks feel about splitting off the rich JS API into a separate post-MVP proposal with its own repo? That way discussion of a richer API could continue without blocking progress or causing uncertainty for the MVP.
tlively commentedon Mar 2, 2022
I've added an agenda item for discussing the JS API to our next meeting: WebAssembly/meetings#982
takikawa commentedon Mar 6, 2022
I won't be able to make it to the upcoming GC subgroup meeting, but I think it's reasonable to move the richer JS API to a separate proposal (while ensuring what's in the initial minimal API doesn't preclude future extensions).
5 remaining items
rossberg commentedon Oct 17, 2022
This looks good to me, except for one point:
Remember that we have established in previous discussion that anyref is the type that modules will want to (and should) use for "abstract" imports, where they do not want to commit to whether these are implemented in Wasm or by host code (while externref should only be used for the much narrower use case of specific "host" imports). So disallowing anyref for calls would break the most common use case.
jakobkummerow commentedon Oct 17, 2022
I suppose we disagree on what "the most common use case" is for the MVP. In my understanding, it is a single-module Wasm app that needs to interact with a JavaScript embedding, and it is well served by this proposal.
The future may hold many other scenarios. I fully expect that we'll allow many more types on the boundary. Eventually.
tlively commentedon Oct 17, 2022
@jakobkummerow, how much complexity would it add to the implementation to allow
anyrefon the boundary?jakobkummerow commentedon Oct 17, 2022
@tlively : Quite a bit. We're prepared to do it eventually, but realistically it won't get prioritized in the next several months at least.
(Background: for startup speed reasons, there's a single signature-independent JS→Wasm call adaptor that's used before signature-specialized wrappers are compiled for hot functions. For the status quo, this adaptor is already ~1000 lines of handwritten assembly per supported platform; it needs to be assembly because it needs to muck with the stack and registers in ways that no compiler supports. Teaching it to do HeapNumber-to-i31ref conversions, and/or other type checks/conversions, is doable but will be annoying. For the time being, we have our hands full with demonstrating performance benefits; features that aren't required for an MVP will have to wait.)
tlively commentedon Oct 18, 2022
I was thinking about this more today and I realized that wasm-split would benefit from allowing arbitrary types on the JS boundary. The placeholder trampoline functions that wasm-split uses to download, compile, and instantiate the secondary module need to forward arguments to and results from secondary functions through JS. In principle we could have wasm-split add extra wrappers to translate to and from externref (or anyref, if we only partially relax the proposed restrictions), but then those wrappers would still be executed even after the secondary module is loaded. If we did the conversions and downcasts implicitly on the JS boundary instead, those extra costs would only be paid for the single placeholder function call that loads the secondary module.
rossberg commentedon Oct 19, 2022
Wrt supporting the same set of reference types for accessing functions, globals, and tables:
As mentioned yesterday, the JS API has a single abstraction for converting values back and forth, which is the pair of To/FromWebAssemblyValue meta functions. These are context-independent. Hence it would be odd, and a notable complication to the semantics, to make them behave differently depending on where they are invoked.
One practical argument that has been brought up yesterday in support of the difference was that for function calls one can implement a wrapper, whereas for globals and tables that's not possible. I don't think that holds any water. Any such function wrapper would have to be implemented in Wasm to do the conversion. By the same line of argument, one could implement global and table accessors in Wasm to do the conversion. Both would introduce an analogue indirection through an auxiliary Wasm function.
Wrt to type reflection: It shouldn't be too hard to design syntactic AST representation for recursive types, in the same manner as we did for other types. The main question to answer is how to express internal recursive references, but that is primarily a question of choice of concrete syntax, not so much semantics.
An interesting question that came up yesterday is whether and how type reflection should handle type equivalence. This problem already exists today: function types have structural equivalence, but that isn't reflected in their AST-style representation of the type reflection API; the same is true for all other types that we represent as objects(*). Users are free to create multiple AST objects that represent the same type – and that seems rather hard to avoid without compromising usability of the API. Recursive types do not really add anything new to this problem, equivalent types still wouldn't imply equal AST object identities.
(*) In fact, it's already true in the bare Wasm 1.0 API, where things like TableDescriptor and MemoryDescriptor are rudimentary types with structural equivalence.
Consequently, if we want to enable reflection on type equivalence, then I think we'd need to provide a dedicated API function for comparing types. This function could then canonicalise its argument internally (and probably cache that), but that would be an implementation detail. We may want a similar function for reflecting on subtyping. But these are extensions that can be considered independently from the GC proposal. And of course, such type comparison functions can already be implemented in user space (though I wouldn't expect the average dev to be able to do so correctly).
tlively commentedon Dec 1, 2022
Can anyone identify any problems with allowing arbitrary GC types everywhere on the boundary with JS and performing an implicit downcast in the
toWebAssemblyValueprocedure in the JS spec? If not, I suggest we move forward with this design.(The actual implementation of this in V8 might not happen immediately, but that's ok. We would implement it before shipping.)
jakobkummerow commentedon Dec 2, 2022
An update on [[Get]]: we've discovered an issue, namely that resolving a promise involves a property lookup for
"then"on the resolution value (see here, step 9). It's probably important to support resolving promises with Wasm objects, so we have two options:undefinedwithout throwing.undefinedwithout throwing. This would put Wasm objects closer to the mental model of "they're likeObject.freeze(Object.create(null))". [1]The conceptual simplicity/consistency of (2) is probably preferable over the exceptionalism of (1), so unless someone has a better idea (or different opinion), we'll update the design sketch and V8's implementation accordingly.
[1] We could make [[GetPrototypeOf]] also not throw to get even closer to that, but IIUC that's an independent decision because we could specify [[Get]] to return without looking at the prototype chain.
eqrion commentedon Dec 7, 2022
That's fine with me.
Going with option (2) also seems like a good idea.
rossberg commentedon Dec 9, 2022
@tlively:
Not currently, though this of course only works with implicit RTTs, so may be expensive to generalise later, e.g., when we introduce generic types. So a meta-level concern with this might be creating expectations that force us on a costly path in the future.
tlively commentedon Dec 13, 2022
At the subgroup meeting today we decided to move forward with allowing arbitrary GC types on the boundary with an implicit downcast (preceded by an
externalize.internalizeif necessary) intoWebAssemblyValue.I'll make a PR adding the contents of the no-frills design doc to this repo, then we can make the change above, then we can close this issue and open new issues for any remaining problems that might come up.
tlively commentedon Mar 23, 2023
MVP-JS.md is now updated to reflect the no-frills approach and the spec documents have been updated as well, so I'll close this issue.