TL;DR
QuickJSAsyncWASMModule.newRuntime() in quickjs-emscripten-core builds its Lifetime disposer with the wrong order — it calls this.callbacks.deleteRuntime(rt_ptr) before this.ffi.QTS_FreeRuntime(rt_ptr). The sync QuickJSWASMModule.newRuntime() does the opposite (with a code comment explaining why). As a result, any freeHostRef callback the C side emits during QTS_FreeRuntime cannot find the runtime in runtimeCallbacks and throws.
The bug is triggered by defining an asyncified host function — it does not have to be called.
Versions
quickjs-emscripten: 0.32.0 (latest on npm)
@jitl/quickjs-wasmfile-release-asyncify: 0.32.0
- node:
v22.20.0
- platform: linux x64
- bug also present on
main (verified against module-asyncify.ts)
Minimal repro
// repro.mjs — defining (not even calling) a newAsyncifiedFunction is enough
import {
newQuickJSAsyncWASMModule,
newVariant,
RELEASE_ASYNC,
} from "quickjs-emscripten";
import RELEASE_ASYNC_VARIANT from "@jitl/quickjs-wasmfile-release-asyncify";
const variant = newVariant(RELEASE_ASYNC, RELEASE_ASYNC_VARIANT);
const wasm = await newQuickJSAsyncWASMModule(variant);
const rt = wasm.newRuntime();
const ctx = rt.newContext();
const fn = ctx.newAsyncifiedFunction("hostFn", async () => ctx.newString("ok"));
ctx.setProp(ctx.global, "hostFn", fn);
fn.dispose();
// Note: hostFn is never invoked.
ctx.dispose();
rt.dispose(); // throws
$ node repro.mjs
Error: QuickJSRuntime(rt = 5333424) not found when trying to free HostRef(id = -2147483648)
at QuickJSEmscriptenModuleCallbacks.freeHostRef (quickjs-emscripten-core/dist/chunk-V2S4ZYJR.mjs:5:7522)
at t (@jitl/quickjs-wasmfile-release-asyncify/dist/emscripten-module.mjs:22:471)
at wasm://wasm/003eb70e:wasm-function[887]:0xa3fcd
at wasm://wasm/003eb70e:wasm-function[767]:0x90ba7
...
at QuickJSAsyncFFI.c [as QTS_FreeRuntime] (.../emscripten-module.mjs:12:55)
at _Lifetime.disposer (.../chunk-TAV5CUKK.mjs:1:1964)
at _Lifetime.dispose (.../chunk-V2S4ZYJR.mjs:1:4471)
A larger script that runs three configurations and shows the bug + workaround + a clean control:
repro-full.mjs (control / bug / workaround)
import {
newQuickJSAsyncWASMModule,
newVariant,
RELEASE_ASYNC,
} from "quickjs-emscripten";
import RELEASE_ASYNC_VARIANT from "@jitl/quickjs-wasmfile-release-asyncify";
const variant = newVariant(RELEASE_ASYNC, RELEASE_ASYNC_VARIANT);
const wasm = await newQuickJSAsyncWASMModule(variant);
function once({ withAsyncifiedFn, mode }) {
let rt, ctx;
if (mode === "newRuntime") {
rt = wasm.newRuntime();
ctx = rt.newContext();
} else {
ctx = wasm.newContext();
rt = ctx.runtime;
}
if (withAsyncifiedFn) {
const fn = ctx.newAsyncifiedFunction("hostFn", async () => ctx.newString("ok"));
ctx.setProp(ctx.global, "hostFn", fn);
fn.dispose();
}
if (mode === "newRuntime") { ctx.dispose(); rt.dispose(); }
else { ctx.dispose(); }
}
function run(label, opts) {
console.log(`\n=== ${label} ===`);
try { once(opts); console.log("clean"); }
catch (e) { console.log("FAILED:", e.message); }
try { once(opts); console.log("second iteration clean"); }
catch (e) { console.log("second iteration FAILED:", e.message); }
}
run("control: newRuntime, no asyncified fn", { mode: "newRuntime", withAsyncifiedFn: false });
run("BUG: newRuntime + asyncified fn (defined, not called)", { mode: "newRuntime", withAsyncifiedFn: true });
run("workaround: newContext + asyncified fn (defined, not called)", { mode: "newContext", withAsyncifiedFn: true });
=== control: newRuntime, no asyncified fn ===
clean
second iteration clean
=== BUG: newRuntime + asyncified fn (defined, not called) ===
FAILED: QuickJSRuntime(rt = 5333432) not found when trying to free HostRef(id = -2147483648)
second iteration FAILED: QuickJSRuntime(rt = 5368240) not found when trying to free HostRef(id = -2147483648)
=== workaround: newContext + asyncified fn (defined, not called) ===
clean
second iteration clean
In the larger consumer where I first hit this, the second dispose also aborts WASM with the QuickJS 0.32-era assertion:
Aborted(Assertion failed: list_empty(&rt->gc_obj_list),
at: ../../vendor/quickjs/quickjs.c,2036,JS_FreeRuntime)
— consistent with #235, #257, and #258, which I think share this root cause whenever the asyncify shim has live host refs at runtime teardown.
Root cause — dispose order is reversed in the async path
Side-by-side from 0.32.0 (and main):
// QuickJSWASMModule.newRuntime — packages/quickjs-emscripten-core/src/module.ts ✅
const rt = new Lifetime(this.ffi.QTS_NewRuntime(), undefined, (rt_ptr) => {
// Free runtime first - this runs GC finalizers that may call back into JS
this.ffi.QTS_FreeRuntime(rt_ptr);
// Then delete callbacks after finalizers have run
this.callbacks.deleteRuntime(rt_ptr);
});
// QuickJSAsyncWASMModule.newRuntime — packages/quickjs-emscripten-core/src/module-asyncify.ts ❌
const rt = new Lifetime(this.ffi.QTS_NewRuntime(), undefined, (rt_ptr) => {
this.callbacks.deleteRuntime(rt_ptr);
this.ffi.QTS_FreeRuntime(rt_ptr);
});
The sync version even carries a comment explaining the correct order. The asyncified path needs the same order — even more so, since the asyncify host-fn shim registers a host ref (the one with id = -2147483648 / INT_MIN we see in the error) that the C side asks the JS side to free during QTS_FreeRuntime. With the lookup map already cleared, QuickJSModuleCallbacks.cToHostCallbacks.freeHostRef throws:
freeHostRef: (_asyncify, rt, host_ref_id) => {
const runtimeCallbacks = this.runtimeCallbacks.get(rt);
if (!runtimeCallbacks)
throw new Error(
`QuickJSRuntime(rt = ${rt}) not found when trying to free HostRef(id = ${host_ref_id})`,
);
runtimeCallbacks.freeHostRef(rt, host_ref_id);
},
Suggested fix
Swap the two lines in QuickJSAsyncWASMModule.newRuntime so the order matches QuickJSWASMModule.newRuntime:
const rt = new Lifetime(this.ffi.QTS_NewRuntime(), undefined, (rt_ptr) => {
- this.callbacks.deleteRuntime(rt_ptr)
- this.ffi.QTS_FreeRuntime(rt_ptr)
+ // Free runtime first - this runs GC finalizers that may call back into JS
+ this.ffi.QTS_FreeRuntime(rt_ptr)
+ // Then delete callbacks after finalizers have run
+ this.callbacks.deleteRuntime(rt_ptr)
})
Happy to send a PR if that's welcome.
Workaround for current consumers
wasmModule.newContext() (which manages its own runtime under the hood) does not exhibit this — the asyncify host refs are torn down before the runtime is released. We've adopted that pattern in our codebase as a stop-gap.
Why this might overlap with #235 / #257 / #258
All three open list_empty(&rt->gc_obj_list) reports happen during JS_FreeRuntime. The QuickJS upstream commit (2025-09-13) that added that assertion is what makes these visible in 0.32.0. In our case, leftover asyncify host refs at the moment of QTS_FreeRuntime are what populate rt->gc_obj_list — the dispose-order inversion above is one concrete way to reach that state. Not claiming it explains all three, but it might explain one or two. Happy to retest #258 against a candidate fix if useful.
TL;DR
QuickJSAsyncWASMModule.newRuntime()inquickjs-emscripten-corebuilds itsLifetimedisposer with the wrong order — it callsthis.callbacks.deleteRuntime(rt_ptr)beforethis.ffi.QTS_FreeRuntime(rt_ptr). The syncQuickJSWASMModule.newRuntime()does the opposite (with a code comment explaining why). As a result, anyfreeHostRefcallback the C side emits duringQTS_FreeRuntimecannot find the runtime inruntimeCallbacksand throws.The bug is triggered by defining an asyncified host function — it does not have to be called.
Versions
quickjs-emscripten:0.32.0(latest on npm)@jitl/quickjs-wasmfile-release-asyncify:0.32.0v22.20.0main(verified againstmodule-asyncify.ts)Minimal repro
A larger script that runs three configurations and shows the bug + workaround + a clean control:
repro-full.mjs (control / bug / workaround)
In the larger consumer where I first hit this, the second dispose also aborts WASM with the QuickJS 0.32-era assertion:
— consistent with #235, #257, and #258, which I think share this root cause whenever the asyncify shim has live host refs at runtime teardown.
Root cause — dispose order is reversed in the async path
Side-by-side from
0.32.0(andmain):The sync version even carries a comment explaining the correct order. The asyncified path needs the same order — even more so, since the asyncify host-fn shim registers a host ref (the one with
id = -2147483648/INT_MINwe see in the error) that the C side asks the JS side to free duringQTS_FreeRuntime. With the lookup map already cleared,QuickJSModuleCallbacks.cToHostCallbacks.freeHostRefthrows:Suggested fix
Swap the two lines in
QuickJSAsyncWASMModule.newRuntimeso the order matchesQuickJSWASMModule.newRuntime:const rt = new Lifetime(this.ffi.QTS_NewRuntime(), undefined, (rt_ptr) => { - this.callbacks.deleteRuntime(rt_ptr) - this.ffi.QTS_FreeRuntime(rt_ptr) + // Free runtime first - this runs GC finalizers that may call back into JS + this.ffi.QTS_FreeRuntime(rt_ptr) + // Then delete callbacks after finalizers have run + this.callbacks.deleteRuntime(rt_ptr) })Happy to send a PR if that's welcome.
Workaround for current consumers
wasmModule.newContext()(which manages its own runtime under the hood) does not exhibit this — the asyncify host refs are torn down before the runtime is released. We've adopted that pattern in our codebase as a stop-gap.Why this might overlap with #235 / #257 / #258
All three open
list_empty(&rt->gc_obj_list)reports happen duringJS_FreeRuntime. The QuickJS upstream commit (2025-09-13) that added that assertion is what makes these visible in 0.32.0. In our case, leftover asyncify host refs at the moment ofQTS_FreeRuntimeare what populatert->gc_obj_list— the dispose-order inversion above is one concrete way to reach that state. Not claiming it explains all three, but it might explain one or two. Happy to retest #258 against a candidate fix if useful.