bytecodealliance/jco

Encountering error with `resource` types

Closed this issue · 5 comments

I have this .wit file

package testdemo:component;

interface clients { 
  resource testdemo-client {
    hello-world: func() -> string;
  }
}

interface events { 
  record foo {
    bar: string,
  }

  resource testdemo-event {
    parse: func(input: string) -> result<foo, string>;
  }

  
}

world testdemo { 
  export clients;
  export events;
}

implemented by this Rust code

wit_bindgen::generate!({
    world: "testdemo",
    exports: {
        "testdemo:component/clients/testdemo-client": TestdemoClients,
        "testdemo:component/events/testdemo-event": TestdemoEvents,
     }});
use exports::testdemo::component::clients::GuestTestdemoClient;
use exports::testdemo::component::events::{Foo, GuestTestdemoEvent};

pub struct TestdemoClients;
pub struct TestdemoEvents;

impl GuestTestdemoClient for TestdemoClients {
    fn hello_world(&self) -> String {
        return "Hello World".to_string();
    }
}

impl GuestTestdemoEvent for TestdemoEvents {
    fn parse(&self, input: String) -> Result<Foo, String> {
        return Ok(Foo { bar: input });
    }
}

Generating this js code via jco with mostly default settings.

jco transpile ./dist/wasm/testdemo_component.wasm -o ./src/generated-js-bindings --no-namespaced-exports
Generated js code hidden for length

const base64Compile = (str) =>
  WebAssembly.compile(
    typeof Buffer !== "undefined"
      ? Buffer.from(str, "base64")
      : Uint8Array.from(atob(str), (b) => b.charCodeAt(0))
  );

class ComponentError extends Error {
  constructor(value) {
    const enumerable = typeof value !== "string";
    super(enumerable ? `${String(value)} (see error.payload)` : value);
    Object.defineProperty(this, "payload", { value, enumerable });
  }
}

let dv = new DataView(new ArrayBuffer());
const dataView = (mem) =>
  dv.buffer === mem.buffer ? dv : (dv = new DataView(mem.buffer));

const isNode =
  typeof process !== "undefined" && process.versions && process.versions.node;
let _fs;
async function fetchCompile(url) {
  if (isNode) {
    _fs = _fs || (await import("fs/promises"));
    return WebAssembly.compile(await _fs.readFile(url));
  }
  return fetch(url).then(WebAssembly.compileStreaming);
}

const instantiateCore = WebAssembly.instantiate;

const resourceHandleSymbol = Symbol("resource");

const symbolDispose = Symbol.dispose || Symbol.for("dispose");

const utf8Decoder = new TextDecoder();

const utf8Encoder = new TextEncoder();

let utf8EncodedLen = 0;
function utf8Encode(s, realloc, memory) {
  if (typeof s !== "string") throw new TypeError("expected a string");
  if (s.length === 0) {
    utf8EncodedLen = 0;
    return 1;
  }
  let allocLen = 0;
  let ptr = 0;
  let writtenTotal = 0;
  while (s.length > 0) {
    ptr = realloc(ptr, allocLen, 1, (allocLen += s.length * 2));
    const { read, written } = utf8Encoder.encodeInto(
      s,
      new Uint8Array(memory.buffer, ptr + writtenTotal, allocLen - writtenTotal)
    );
    writtenTotal += written;
    s = s.slice(read);
  }
  utf8EncodedLen = writtenTotal;
  return ptr;
}

let exports0;
let exports1;
let exports2;
let memory0;
let postReturn0;
let realloc0;
let postReturn1;

class TestdemoClient {}

TestdemoClient.prototype.helloWorld = function helloWorld() {
  var handle0 = this[resourceHandleSymbol];

  if (handle0 === null) {
    throw new Error('Resource error: "TestdemoClient" lifetime expired.');
  }
  if (handle0 === undefined) {
    throw new Error('Resource error: Not a valid "TestdemoClient" resource.');
  }

  const ret =
    exports1["testdemo:component/clients#[method]testdemo-client.hello-world"](
      handle0
    );
  if (handleTable0.get(handle0)) {
    throw new Error("Resource error: borrows were not dropped");
  }
  var ptr1 = dataView(memory0).getInt32(ret + 0, true);
  var len1 = dataView(memory0).getInt32(ret + 4, true);
  var result1 = utf8Decoder.decode(new Uint8Array(memory0.buffer, ptr1, len1));
  postReturn0(ret);
  return result1;
};

class TestdemoEvent {}

TestdemoEvent.prototype.parse = function parse(arg1) {
  var handle0 = this[resourceHandleSymbol];
  if (handle0 === null) {
    throw new Error('Resource error: "TestdemoEvent" lifetime expired.');
  }
  if (handle0 === undefined) {
    throw new Error('Resource error: Not a valid "TestdemoEvent" resource.');
  }

  var ptr1 = utf8Encode(arg1, realloc0, memory0);
  var len1 = utf8EncodedLen;
  const ret = exports1[
    "testdemo:component/events#[method]testdemo-event.parse"
  ](handle0, ptr1, len1);
  if (handleTable2.get(handle0)) {
    throw new Error("Resource error: borrows were not dropped");
  }
  let variant4;
  switch (dataView(memory0).getUint8(ret + 0, true)) {
    case 0: {
      var ptr2 = dataView(memory0).getInt32(ret + 4, true);
      var len2 = dataView(memory0).getInt32(ret + 8, true);
      var result2 = utf8Decoder.decode(
        new Uint8Array(memory0.buffer, ptr2, len2)
      );
      variant4 = {
        tag: "ok",
        val: {
          bar: result2,
        },
      };
      break;
    }
    case 1: {
      var ptr3 = dataView(memory0).getInt32(ret + 4, true);
      var len3 = dataView(memory0).getInt32(ret + 8, true);
      var result3 = utf8Decoder.decode(
        new Uint8Array(memory0.buffer, ptr3, len3)
      );
      variant4 = {
        tag: "err",
        val: result3,
      };
      break;
    }
    default: {
      throw new TypeError("invalid variant discriminant for expected");
    }
  }
  postReturn1(ret);
  if (variant4.tag === "err") {
    throw new ComponentError(variant4.val);
  }
  return variant4.val;
};
const handleTable0 = new Map();
let handleCnt0 = 0;
const finalizationRegistry0 = new FinalizationRegistry((handle) => {
  const handleEntry = handleTable0.get(handle);
  if (handleEntry) {
    handleTable0.delete(handle);

    if (handleEntry.own) {
      exports0["0"](handleEntry.rep);
    }
  }
});

const handleTable2 = new Map();
let handleCnt2 = 0;
const finalizationRegistry2 = new FinalizationRegistry((handle) => {
  const handleEntry = handleTable2.get(handle);
  if (handleEntry) {
    handleTable2.delete(handle);

    if (handleEntry.own) {
      exports0["1"](handleEntry.rep);
    }
  }
});

const $init = (async () => {
  const module0 = fetchCompile(
    new URL("./testdemo_component.core.wasm", import.meta.url)
  );
  const module1 = base64Compile(
    "AGFzbQEAAAABBQFgAX8AAwMCAAAEBQFwAQICBxQDATAAAAExAAEIJGltcG9ydHMBAAoVAgkAIABBABEAAAsJACAAQQERAAALAC4JcHJvZHVjZXJzAQxwcm9jZXNzZWQtYnkBDXdpdC1jb21wb25lbnQGMC4xOC4wAI0BBG5hbWUAExJ3aXQtY29tcG9uZW50OnNoaW0BcQIAN2R0b3ItW2V4cG9ydF10ZXN0ZGVtbzpjb21wb25lbnQvY2xpZW50cy10ZXN0ZGVtby1jbGllbnQBNWR0b3ItW2V4cG9ydF10ZXN0ZGVtbzpjb21wb25lbnQvZXZlbnRzLXRlc3RkZW1vLWV2ZW50"
  );
  const module2 = base64Compile(
    "AGFzbQEAAAABBQFgAX8AAhoDAAEwAAAAATEAAAAIJGltcG9ydHMBcAECAgkIAQBBAAsCAAEALglwcm9kdWNlcnMBDHByb2Nlc3NlZC1ieQENd2l0LWNvbXBvbmVudAYwLjE4LjAAHARuYW1lABUUd2l0LWNvbXBvbmVudDpmaXh1cHM"
  );
  ({ exports: exports0 } = await instantiateCore(await module1));
  ({ exports: exports1 } = await instantiateCore(await module0));
  ({ exports: exports2 } = await instantiateCore(await module2, {
    "": {
      $imports: exports0.$imports,
      0: exports1["testdemo:component/clients#[dtor]testdemo-client"],
      1: exports1["testdemo:component/clients#[dtor]testdemo-client"],
    },
  }));
  memory0 = exports1.memory;
  postReturn0 =
    exports1[
      "cabi_post_testdemo:component/clients#[method]testdemo-client.hello-world"
    ];
  realloc0 = exports1.cabi_realloc;
  postReturn1 =
    exports1[
      "cabi_post_testdemo:component/events#[method]testdemo-event.parse"
    ];
})();

await $init;
const clients = {
  TestdemoClient: TestdemoClient,
};
const events = {
  TestdemoEvent: TestdemoEvent,
};

export { clients, events };

Trying to run either of the code snippets below results in an error:

const testClient = new clients.TestdemoClient();
const output = testClient.helloWorld()

//or

const testEvent = new events.TestdemoEvent();
const parsedEvent = testEvent.parse("ATestString");

Throws:

Error: Resource error: Not a valid "TestdemoClient" resource.
 ❯ TestdemoClient.helloWorld src/generated-js-bindings/testdemo_component.js:83:11
     81|   }
     82|   if (handle0 === undefined) {
     83|     throw new Error('Resource error: Not a valid "TestdemoClient" resource.');
       |           ^
     84|   }
     85| 

//and

Error: Resource error: Not a valid "TestdemoEvent" resource.
 ❯ TestdemoEvent.parse src/generated-js-bindings/testdemo_component.js:108:11
    106|   }
    107|   if (handle0 === undefined) {
    108|     throw new Error('Resource error: Not a valid "TestdemoEvent" resource.');
       |           ^
    109|   }
    110| 

And looking through the generated js that makes sense because var handle0 = this[resourceHandleSymbol]; and I am not seeing anywhere that this[resourceHandleSymbol] is actually being set anywhere, so handle0 would be expected to be undefined.

I'll try to get up a repro of this on my github, but at the moment the code for it is tied up with some other things

My suggestion here would be to implement an empty resource constructor, so that the constructor is actually generated by the bindgen which will then set the handle.

This might be a bug to do with supporting resource creation outside of the component model constructs - ie creating a correct empty JS constructor by default.

Thanks for the quick reply, that workaround worked perfectly! The generated code now creates a constructor for the resource classes that properly sets this[resourceHandleSymbol] (see below). Seems like at least this chunk of the constructor should be generated whether the constructor exists or not.

class TestdemoClient{
  constructor() {
    const ret = exports1['testdemo:component/clients#[constructor]testdemo-client']();
    var handle1 = ret;
    var rsc0 = new.target === TestdemoClient ? this : Object.create(TestdemoClient.prototype);
    var rep2 = handleTable0.get(handle1).rep;
    Object.defineProperty(rsc0, resourceHandleSymbol, { writable: true, value: rep2});
    finalizationRegistry0.register(rsc0, handle1, rsc0);
    Object.defineProperty(rsc0, symbolDispose, { writable: true, value: function () {} });
    
    handleTable0.delete(handle1);
    return rsc0;
  }
}

Looking into this further, the bug is not that this doesn't work, but that it kind of does.

That is - resources need to be defined as constructible within the component model itself - if there is no constructor then there is no construction!

So the bug fix here is actually to make the JS constructor error in the case where there is no constructor for a resource.

I will add a PR to this effect soon.

That is - resources need to be defined as constructible within the component model itself - if there is no constructor then there is no construction!

I'm not quite sure I am getting that from the wit docs I have been looking at. Specifically the section on resource types says:

Lastly, a resource statement can contain at most one constructor function, which is syntactic sugar for a function returning a handle of the containing resource type.

If a resource must have a constructor I would expect that to say something like "a resource statement must contain exactly one constructor function".

So if thats definitely correct I'll create an issue there requesting clarification in the wit docs. Additionally I would expect the Rust wit_bindgen::generate! macro to error on resources with no constructor method so I'll create an issue in that repo too.

Slight aside, is there a Zulip or anything for following jco progress? I don't see one mentioned in the docs, but would be interested in keeping up with things!

Resources don't need to have constructors defined, if the component that defines the resource will always itself be creating the resource instances and returning those handles out to other users.

Also WASI host resources don't have constructors for the same reason - the host is the only one who can create those objects to begin with.

But yes, if you want a component to define a resource, where that resource can ever be constructed outside of the component that defines it, then there must be a constructor.

Added a Zulip link to the readme, thanks for the bump on that.