Skip to content

QuickJSAsyncWASMModule.newRuntime: dispose order calls deleteRuntime before QTS_FreeRuntime, breaking freeHostRef for asyncified host fns #261

@Rifampin

Description

@Rifampin

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions