Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JS interop: no-frills approach #279

Closed
jakobkummerow opened this issue Feb 22, 2022 · 30 comments
Closed

JS interop: no-frills approach #279

jakobkummerow opened this issue Feb 22, 2022 · 30 comments

Comments

@jakobkummerow
Copy link
Contributor

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?

@jakobkummerow
Copy link
Contributor Author

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
Copy link
Member

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:

;; module A
(type $point (struct (field $x f64) (field $y f64)))
(global $point_t (import "" "point_t") (rtt $point))

(func (export "make_point") (param f64 f64) (result (ref $point))
  (struct.new (local.get 0) (local.get 1) (global.get $point_t))
)

;; module B
(type $size (struct (field $width f64) (field $height f64))) 
(global $size_t (import "" "size_t") (rtt $size))

(func (export "make_size") (param f64 f64) (result (ref $point))
  (struct.new (local.get 0) (local.get 1) (global.get $size_t))
)

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:

let point_proto = {distance() { return (this.x + this.y)/2 }}
let point_t = new WebAssembly.Struct({x: "f64", y: "f64"}, point_proto)
let A = WebAssembly.instantiate(moduleA, {"": {point_t: new Global({value: point_t})}})

let size_t = new WebAssembly.Struct({width: "f64", height: "f64"})
let B = WebAssembly.instantiate(moduleB, {"": {size_t: new Global({value: size_t})}})

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 for size_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:

let p = A.exports.make_point(1, 2)
let {x, y} = p
let l = p.distance()

let s = B.exports.make_size(4, 5)
let a = s.width * s.height

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
Copy link
Member

kripken commented 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:

struct Parent {
  int attr;
};

The following bindings code is emitted in cpp:

int EMSCRIPTEN_KEEPALIVE emscripten_bind_Parent_get_attr_0(Parent* self) {
  return self->attr;
}

And the emitted JS looks like this:

Parent.prototype.get_attr = function() {
  return _emscripten_bind_Parent_get_attr_0(this.ptr); // ptr is the address in linear memory of the object
};

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 i32 pointers (and also cycles could be collected).

@tlively
Copy link
Member

tlively commented Feb 22, 2022

We could perhaps make the no-frills approach more idiomatic by adding one small frill: interpreting anyref fields at index zero as prototypes. Instead of importing RTT values as in @rossberg's sketch, the module would instead import anyref prototype objects as globals and pass them explicitly to struct.new or struct.set just 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
Copy link
Member

interpreting anyref fields at index zero as prototypes

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
Copy link
Contributor

takikawa commented 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?

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"

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.Function constructor, JS can supply functions that can inhabit the funcref type. 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
Copy link
Contributor Author

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:

class Size {
  constructor(width, height) {
    this.#wasm_size = module.exports.make_size(width, height);
  }
  // Example for a getter:
  width() {
    return module.exports.size_get_width(this.#wasm_size);
  }
  height() {
    return module.exports.size_get_height(this.#wasm_size);
  }
  // Example for an arbitrary custom method:
  area() {
    return this.width() * this.height();
  }
}

So even for idiomatic JavaScript, the only thing we really need is an externref equivalent 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.width access 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
Copy link

dcodeIO commented Feb 28, 2022

Wondering how the externref typed field referring back to the JS wrapper would look and behave. Is the following about what you'd imagine?

class Element {
  constructor() {
    this._ref = module.exports.new_element();
  }
}
class Box {
  constructor(element) {
    this._ref = module.exports.new_box(element?._ref || null);
  }
  get element() {
    let elementWrapper = module.exports.box_get_element_wrapper(this._ref);
    if (!elementWrapper) {
      let ref = module.exports.box_get_element(this._ref);
      if (ref) {
        elementWrapper = Object.create(Element.prototype);
        elementWrapper.constructor = Element;
        elementWrapper._ref = ref;
        module.exports.box_set_element_wrapper(this._ref, elementWrapper);
      }
    }
    return elementWrapper;
  }
  set element(element) {
    module.exports.box_set_element(this._ref, element._ref);
    module.exports.box_set_element_wrapper(this._ref, element);
  }
}

@jakobkummerow
Copy link
Contributor Author

Something like that, yeah, modulo details. One key point: the Wasm type for Element would hold its own wrapper (for reusability/encapsulation), so Box would only need a single element field. And then you can choose whether you prefer Object.create or an "overloaded" constructor; the latter would get more convenient than the sketch below if we specified that typeof returns "wasmobject" (or similar) for Wasm objects.

(type $Size (struct
  (field $wrapper externref)
  (field $width f64)
  (field $height f64)
))
class Size {
  constructor(width_or_wasm, height) {
    if (typeof width_or_wasm === "number") {
      this.#wasm_ref = module.exports.make_size(width_or_wasm, height);
      module.exports.size_set_wrapper(this.#wasm_ref, this);
    } else {
      // Assume {width_or_wasm} is an existing Wasm object.
      this.#wasm_ref = width_or_wasm;
    }
  }
  static fromWasm(wasm) {
    let wrapper = module.exports.size_get_wrapper(wasm);
    if (wrapper === null) {
      wrapper = new Size(wasm);
      module.exports.size_set_wrapper(wasm, wrapper);
    }
    return wrapper;
  }
  toWasm() {
    return this.#wasm_ref;
  }
}
class Rect {
  constructor(top, left, width, height) {
    let topleft = new Point(top, left);
    let size = new Size(width, height);
    this.#wasm_ref = module.exports.make_rect(topleft.toWasm(), size.toWasm());
  }
  toWasm() {
    return this.#wasm_ref;
  }
  get size() {
    return Size.fromWasm(module.exports.rect_get_size(this.toWasm()));
  }
  set size(new_size) {
    module.exports.rect_set_size(this.toWasm(), new_size.toWasm());
  }
}

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 a fromWasm method, 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 wrapper field, and then the JS-side fromWasm() could be an unconditional one-liner.

@carlopi
Copy link

carlopi commented 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:

class WasmGCObject {
     get (index) {
           if (index > this.maxIndex())
                 return null; //Or some other failure mode, unsure
           return this.get<index>();
     }
     set (index, field) {
           if (index > this.maxIndex())
                  return; //Or some other failure mode, unsure
           this.set<index>(field);
     }
     maxIndex() {
            return currently_max_valid_Index; //this would be a static known values for structs but a variable for dynamic arrays
     }
}
// Where get<i> or set<i> retrieve the i-th field of a given struct like:
// array -> get i-th item, maxIndex is variable
// struct -> get i-th field, maxIndex is known
// others -> only 0 is ever valid, maxIndex is known

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:

  • less boilerplate, since once these 'magic' functions are implicit there there is no need to wrap accessors in at the JS-Wasm or Wasm-Wasm interface
  • performance: the accessors being known functions means they can be more easily optimized (eg. if it can be proven that index is always 0 for a given callsite)
  • composability, since all the rest remains possible, but this functionality unlocks easily accessing and modifying existing items

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
Copy link

dcodeIO commented 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 externref field 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
Copy link
Contributor Author

@carlopi and @dcodeIO :

require adding a round-trip [...]
necessary glue [...]

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.

more practical if toolchains like Binaryen could reason about what's needed and what's not

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.)

having to parse glue code in various languages

Nothing in the no-frills approach suggests that anyone should have a reason to parse glue code in any language.

@tlively
Copy link
Member

tlively commented 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
Copy link
Member

tlively commented Mar 2, 2022

I've added an agenda item for discussing the JS API to our next meeting: WebAssembly/meetings#982

@takikawa
Copy link
Contributor

takikawa commented 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).

@askeksa-google
Copy link

I also find the no-frills approach to be a practical, clean and unambiguous way to define object access, provided the performance is satisfactory. I like the way it encapsulates WasmGC types and object layout.

Even without any object access features beyond this, we should still define as part of the interop how WasmGC objects behave in various JS operations. I have requested support for WeakMap and FinalizationRegistry in #235, and other operations (e.g. equality operators) could be useful to specify too.

@tlively
Copy link
Member

tlively commented Mar 9, 2022

We agreed at the subgroup meeting yesterday to split out a JS API for customizing accessors and prototypes for GC objects as a post-MVP proposal (https://github.com/WebAssembly/gc-js-customization), so for the MVP we'll be going with something that looks like the no-frills approach described here, at least for structs. We discussed a few options for how arrays might be accessed or created more directly from JS and we might choose to add some frills for arrays in the MVP if we can get a performance benefit from doing so.

I'll close this issue for now, but feel free to reopen it if you have anything to add. Options for arrays should probably be discussed in fresh issues.

@tlively tlively closed this as completed Mar 9, 2022
@jakobkummerow
Copy link
Contributor Author

Some of us have been working on fleshing out the details of what exactly a "no-frills" approach would mean in practice: https://docs.google.com/document/d/17hCQXOyeSgogpJ0I0wir4LRmdvu4l7Oca6e1NkbVN8M/edit#

As a quick summary:

  • the key idea is to be minimal (and extend later)
  • trying to access a Wasm object's prototype throws, to align with JS Shared Structs
  • trying to read, write, or delete properties throws, to maximize forwards compatibility with whichever longer-term design we come up with
  • checking presence of properties or enumerating them returns false / empty-list
  • function calls only allow externref and funcref (as they already do today), converting to/from anyref is done explicitly on the Wasm side. Struct fields and array elements can be accessed via exported functions.

These restrictions aren't meant to be there forever; they're meant to give us an initial, basic, "good-enough" solution, so we can finalize and ship the WasmGC proposal while taking the time we need to come up with richer JS interop. The module producers we talked to agreed that this basic feature set is enough to address their immediate needs.

Please see the document for details and FAQ.

@jakobkummerow jakobkummerow reopened this Oct 13, 2022
@rossberg
Copy link
Member

rossberg commented Oct 17, 2022

This looks good to me, except for one point:

  • function calls only allow externref and funcref (as they already do today), converting to/from anyref is done explicitly on the Wasm side.

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
Copy link
Contributor Author

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
Copy link
Member

tlively commented Oct 17, 2022

@jakobkummerow, how much complexity would it add to the implementation to allow anyref on the boundary?

@jakobkummerow
Copy link
Contributor Author

@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
Copy link
Member

tlively commented 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
Copy link
Member

Wrt supporting the same set of reference types for accessing functions, globals, and tables:

  1. 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.

  2. 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
Copy link
Member

tlively commented 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 toWebAssemblyValue procedure 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
Copy link
Contributor Author

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:

  1. Put an exception into the spec. Roughly: when [[Get]] is called by a Promise Resolve Function, it returns undefined without throwing.
  2. Change the behavior of [[Get]] in general to return undefined without throwing. This would put Wasm objects closer to the mental model of "they're like Object.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
Copy link
Contributor

eqrion commented Dec 7, 2022

Can anyone identify any problems with allowing arbitrary GC types everywhere on the boundary with JS and performing an implicit downcast in the toWebAssemblyValue procedure 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.)

That's fine with me.

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.

Going with option (2) also seems like a good idea.

@rossberg
Copy link
Member

rossberg commented Dec 9, 2022

@tlively:

Can anyone identify any problems with allowing arbitrary GC types everywhere on the boundary with JS and performing an implicit downcast in the toWebAssemblyValue procedure in the JS spec?

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
Copy link
Member

tlively commented 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.internalize if necessary) in toWebAssemblyValue.

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
Copy link
Member

tlively commented 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

9 participants