From 64b84dd61b0c5a3752a39a0953bbd23ba5bc11c3 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:07:32 -0700 Subject: [PATCH 1/4] test: add failing reproduction tests for 3 bugs - bug_static_method_ignores_interface: static resource methods look up in globals instead of the interface object - bug_async_export_rejection_swallowed: void async export rejection is silently swallowed via task_return with empty stack - bug_root_level_flags_dropped: partition_imports skips root-level flags/enums/variants defined directly in a world Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- tests/wit_types.rs | 168 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/tests/wit_types.rs b/tests/wit_types.rs index 093a5f3..152e6ee 100644 --- a/tests/wit_types.rs +++ b/tests/wit_types.rs @@ -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" + ); +} From 459c76a1eff1d6b6cd22b29a2d96fceb820f0e6a Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:12:31 -0700 Subject: [PATCH 2/4] fix: look up static methods from interface object The [static] handler looked up the method directly in globals, ignoring func.interface(). This meant static methods on resources inside an interface would fail at runtime. Mirror the constructor/method pattern: when func.interface() is Some, look up the resource class from the interface object first. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- crates/runtime/src/interpreter.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/crates/runtime/src/interpreter.rs b/crates/runtime/src/interpreter.rs index 35c2430..e502f14 100644 --- a/crates/runtime/src/interpreter.rs +++ b/crates/runtime/src/interpreter.rs @@ -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::(args) From a7b98b242f1738c8b81c4c8a5d04c0bf900513b0 Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:14:40 -0700 Subject: [PATCH 3/4] fix: trap with message on async export rejection The catch_cb closure called task_return with an empty value stack, which silently swallowed rejections for void async exports. Replace with a panic that includes the JS error message so the host sees a trap instead of a false Ok. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- crates/runtime/src/bindings.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/crates/runtime/src/bindings.rs b/crates/runtime/src/bindings.rs index 2c50851..a740e78 100644 --- a/crates/runtime/src/bindings.rs +++ b/crates/runtime/src/bindings.rs @@ -250,11 +250,18 @@ fn build_async_exports<'js>( let catch_cb = Function::new( ctx.clone(), - coerce_fn(move |ctx: Ctx<'_>, _args: Rest>| { - 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>| { + 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}"); }), )?; From 844c94159a68ad1cf7fdc008c13bf534678d3f8e Mon Sep 17 00:00:00 2001 From: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> Date: Mon, 16 Mar 2026 14:16:06 -0700 Subject: [PATCH 4/4] fix: include root-level flags/enums/variants in partition_imports partition_imports guarded flags, enums, and variants with if *.interface().is_some(), silently dropping any defined at the world root level. The WIT spec allows typedef-item inside a world, so remove the guards to match how functions are already handled. Signed-off-by: Ludvig Liljenberg <4257730+ludfjig@users.noreply.github.com> --- crates/runtime/src/bindings.rs | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/crates/runtime/src/bindings.rs b/crates/runtime/src/bindings.rs index a740e78..c17ee12 100644 --- a/crates/runtime/src/bindings.rs +++ b/crates/runtime/src/bindings.rs @@ -38,25 +38,19 @@ fn partition_imports(wit: Wit) -> HashMap, 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 }