Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 21 additions & 20 deletions crates/runtime/src/bindings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,19 @@ fn partition_imports(wit: Wit) -> HashMap<Option<&'static str>, WitInterface> {
ret.entry(func.interface()).or_default().funcs.push(func);
}
for flags in wit.iter_flags() {
if flags.interface().is_some() {
ret.entry(flags.interface()).or_default().flags.push(flags);
}
ret.entry(flags.interface()).or_default().flags.push(flags);
}
for enum_ty in wit.iter_enums() {
if enum_ty.interface().is_some() {
ret.entry(enum_ty.interface())
.or_default()
.enums
.push(enum_ty);
}
ret.entry(enum_ty.interface())
.or_default()
.enums
.push(enum_ty);
}
for variant in wit.iter_variants() {
if variant.interface().is_some() {
ret.entry(variant.interface())
.or_default()
.variants
.push(variant);
}
ret.entry(variant.interface())
.or_default()
.variants
.push(variant);
}
ret
}
Expand Down Expand Up @@ -250,11 +244,18 @@ fn build_async_exports<'js>(

let catch_cb = Function::new(
ctx.clone(),
coerce_fn(move |ctx: Ctx<'_>, _args: Rest<Value<'_>>| {
let func = ctx.wit().export_func(func_index);
let mut call = QjsCallContext::default();
func.call_task_return(&mut call);
Ok(Value::new_undefined(ctx))
coerce_fn(move |ctx: Ctx<'_>, args: Rest<Value<'_>>| {
let reason = args
.0
.into_iter()
.next()
.unwrap_or_else(|| Value::new_undefined(ctx.clone()));
let msg = reason
.as_object()
.and_then(|obj| obj.get::<_, rquickjs::String>("message").ok())
.and_then(|s| s.to_string().ok())
.unwrap_or_else(|| format!("{reason:?}"));
panic!("async export rejected: {msg}");
}),
)?;

Expand Down
24 changes: 19 additions & 5 deletions crates/runtime/src/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,17 +114,31 @@ impl Interpreter for QjsInterpreter {
}
});
} else if let Some(rest) = name.strip_prefix("[static]") {
// Static resource method: like a regular function on the class
let (_resource, method_name) = rest
// Static resource method: look up Class on the interface object,
// then call the static method on the class.
let (resource, method_name) = rest
.split_once('.')
.unwrap_or_else(|| panic!("invalid static method name: {name}"));

with_ctx(|ctx| {
let method_name = fn_lookup(ctx, method_name);
let class_name = resource.to_upper_camel_case();
let globals = ctx.globals();
let js_func: rquickjs::Function = globals
.get(method_name)
.unwrap_or_else(|e| panic!("Failed to get '{}': {:?}", method_name, e));
let class_obj: rquickjs::Object = if let Some(iface) = func.interface() {
let iface_obj: rquickjs::Object = globals
.get(iface_lookup(ctx, iface))
.unwrap_or_else(|e| panic!("interface '{}' not found: {:?}", iface, e));
iface_obj
.get(class_name.as_str())
.unwrap_or_else(|e| panic!("class '{}' not found: {:?}", class_name, e))
} else {
globals
.get(class_name.as_str())
.unwrap_or_else(|e| panic!("class '{}' not found: {:?}", class_name, e))
};
let js_func: rquickjs::Function = class_obj.get(method_name).unwrap_or_else(|e| {
panic!("static method '{}' not found: {:?}", method_name, e)
});
let args = cx.stack_into_args(ctx);
let result = js_func
.call_arg::<Value>(args)
Expand Down
168 changes: 168 additions & 0 deletions tests/wit_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1588,3 +1588,171 @@ fn test_exported_resource() {
"value should be 43 after increment"
);
}

#[test]
fn test_static_resource_method_in_interface() {
let dir = tempfile::TempDir::new().unwrap();
let wit_path = dir.path().join("test.wit");
std::fs::write(
&wit_path,
r#"
package test:static-bug;

interface widget-api {
resource widget {
constructor(name: string);
get-name: func() -> string;
create-default: static func() -> widget;
}
}

world static-test {
export widget-api;
}
"#,
)
.unwrap();

let opts = componentize_qjs::ComponentizeOpts {
wit_path: &wit_path,
js_source: r#"
class Widget {
constructor(name) { this.name = name; }
getName() { return this.name; }
static createDefault() { return new Widget("default"); }
}
globalThis.widgetApi = { Widget };
"#,
world_name: None,
stub_wasi: true,
};

let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();

let wasm = rt.block_on(componentize_qjs::componentize(&opts)).unwrap();

let engine = common::engine();
let component = wasmtime::component::Component::new(engine, &wasm).unwrap();

let mut wasi_builder = wasmtime_wasi::WasiCtxBuilder::new();
let wasi = wasi_builder.build();
let table = wasmtime::component::ResourceTable::new();
let mut store = wasmtime::Store::new(engine, common::WasiCtxState { wasi, table });

let mut linker = wasmtime::component::Linker::new(engine);
wasmtime_wasi::p2::add_to_linker_sync(&mut linker).unwrap();
let instance = linker.instantiate(&mut store, &component).unwrap();

let iface_idx = instance
.get_export_index(&mut store, None, "test:static-bug/widget-api")
.expect("interface export not found");

// Call the static method — this should work but panics because
// the runtime looks for "createDefault" in globals instead of the
// interface object.
let static_idx = instance
.get_export_index(
&mut store,
Some(&iface_idx),
"[static]widget.create-default",
)
.expect("[static]widget.create-default not found");
let static_fn = instance.get_func(&mut store, static_idx).unwrap();

let mut results = [Val::Bool(false)];
static_fn.call(&mut store, &[], &mut results).unwrap();

// If we got here, we have a resource handle. Verify it works.
let get_name_idx = instance
.get_export_index(&mut store, Some(&iface_idx), "[method]widget.get-name")
.expect("[method]widget.get-name not found");
let get_name = instance.get_func(&mut store, get_name_idx).unwrap();

let mut name_results = [Val::Bool(false)];
get_name
.call(&mut store, &results, &mut name_results)
.unwrap();
assert_eq!(
name_results[0],
Val::String("default".into()),
"static factory should produce widget with name 'default'"
);
}

#[tokio::test]
async fn test_async_export_rejection_propagates() {
let mut instance = TestCase::new()
.wit(
r#"
package test:async-reject;
world async-reject {
export will-throw: async func();
}
"#,
)
.script(
r#"
async function willThrow() {
throw new Error("this should not be silently swallowed");
}
"#,
)
.build_async()
.await
.unwrap();

// The host calls a void async export that throws. The rejection should
// propagate as an error, not be silently swallowed.
let result = instance.call_async("will-throw", &[], 0).await;
assert!(
result.is_err(),
"async export that throws should return an error, not Ok"
);
}

#[test]
fn test_root_level_flags() {
let result = TestCase::new()
.wit(
r#"
package test:root-flags;
world root-flags {
flags permissions {
read,
write,
execute,
}
export check: func(p: permissions) -> string;
}
"#,
)
.script(
r#"
function check(p) {
const parts = [];
if (p & Permissions.Read) parts.push("read");
if (p & Permissions.Write) parts.push("write");
if (p & Permissions.Execute) parts.push("execute");
return parts.join(",");
}
"#,
)
.stub_wasi()
.build();

let mut instance = result.expect(
"componentization should succeed for world-level flags — \
if this fails, partition_imports is dropping root-level types",
);

let flags_val = Val::Flags(vec!["read".into(), "execute".into()]);
let ret = instance.call1("check", &[flags_val]);
assert_eq!(
ret,
Val::String("read,execute".into()),
"root-level flags should round-trip through the component"
);
}
Loading