Skip to content

list_empty(&rt->gc_obj_list) assertion on dispose after allocating many objects inside a promise job (minimal repro, no host functions) #269

Open

Description

@jackkav

Allocating many objects inside a promise job leaves the runtime un-freeable: context.dispose() aborts the WASM module on assert(list_empty(&rt->gc_obj_list)) in JS_FreeRuntime. Notably the objects themselves have already been collected by then (see the memory numbers below), so this does not look like simple retention.

Probably the same root cause as #235, filed separately because this reproducer is minimal: no host functions, no Scope, no newPromise, no memory limit, no async host work. Just VM-side allocation in a job, then dispose.

Reproducer

import { newQuickJSWASMModuleFromVariant } from 'quickjs-emscripten-core'
import variant from '@jitl/quickjs-wasmfile-release-sync'

const QuickJS = await newQuickJSWASMModuleFromVariant(variant)
const vm = QuickJS.newContext()

// A plain promise job. No host function, no await, no bridge.
const r = vm.evalCode(
  `Promise.resolve().then(() => {` +
  `  const a = [];` +
  `  for (let i = 0; i < 200000; i++) { a.push({ i }); }` +
  `  globalThis.__k = a.length;` +
  `});`
)
if (r.error) { r.error.dispose() } else { r.value.dispose() }

vm.runtime.executePendingJobs().dispose()

const h = vm.getProp(vm.global, '__k')
console.log('__k =', vm.dump(h))   // 200000 — the script ran fine
h.dispose()

vm.dispose()                        // 💥 abort
Aborted(Assertion failed: list_empty(&rt->gc_obj_list), at: ../../vendor/quickjs/quickjs.c,2036,JS_FreeRuntime)

The same thing written with an async IIFE + await aborts identically, so it is not specific to async functions:

vm.evalCode(`(async()=>{await Promise.resolve();` +
  `const a=[];for(let i=0;i<200000;i++){a.push({i});}` +
  `globalThis.__k=a.length;})();`)

Affects both C engines

Reproduced at 0.32.0 on both, which suggests the glue/FFI layer rather than one engine:

variant result
@jitl/quickjs-wasmfile-release-sync Aborted(… list_empty(&rt->gc_obj_list), at: ../../vendor/quickjs/quickjs.c,2036,JS_FreeRuntime)
@jitl/quickjs-wasmfile-debug-sync same
@jitl/quickjs-ng-wasmfile-release-sync Aborted(… list_empty(&rt->gc_obj_list), at: ../../vendor/quickjs-ng/quickjs-amalgam.c,12520,JS_FreeRuntime)
@jitl/quickjs-ng-wasmfile-debug-sync same

What narrows it

All at 200k iterations, each run in a fresh process:

case result
job + retained array of objects abort
job + retained array of objects, a = null before the job returns abort
job + objects allocated but never retained clean
job + retained array of numbers clean
job + 200k-char string built by concatenation clean
same allocation synchronously (no job), 200k and 300k clean

So the trigger is many GC-tracked objects allocated during a promise job. Reachability at the end of the job is irrelevant — nulling the array still aborts.

Threshold on this machine is between 50k and 100k objects for a JSON.parse(JSON.stringify(...)) variant of the same script.

The objects are already gone when it aborts

runtime.computeMemoryUsage() immediately before the aborting dispose(), for the 200k-object .then reproducer above:

{
  "malloc_limit": 4294967295,
  "memory_used_size": 86426,
  "obj_count": 331,
  "prop_count": 1747,
  "array_count": 2
}

86KB used and 331 objects — the 200k objects have been collected, and there is no memory pressure. So whatever is left in gc_obj_list is small, and the allocation volume appears to matter only because it forces a GC cycle during the job rather than because anything is being retained. That is a hypothesis, not something I have confirmed at the C level — I have no build with -sASSERTIONS to dump the list contents.

Ruled out (host-side)

None of these change the outcome:

  • Not calling runtime.setMemoryLimit() at all, or raising it to 512MB
  • Not calling runtime.setInterruptHandler()
  • Polling context.getPromiseState() instead of context.resolvePromise() (so no host newFunction callbacks are installed at all)
  • Extra executePendingJobs() passes after the job completes
  • runtime.computeMemoryUsage() before dispose (per the suggestion on Reproducible list_empty assertion failure #235)
  • Forcing further allocation afterwards to provoke a GC

computeMemoryUsage() shows no memory pressure at all (numbers above), so this is not the near-OOM situation in #257.

Environment

  • quickjs-emscripten / quickjs-emscripten-core / @jitl/* all 0.32.0 (current latest)
  • Node v24.14.0, darwin arm64

Why it matters

We hit this in Insomnia, running user-authored pre-request scripts in QuickJS. Any script that does await someHostCall() and then parses a response body of a few MB crashes the whole worker — and because it is a native abort() rather than a thrown error, it cannot be caught and reported as a script failure. The abort escapes context.dispose() in a finally block, replacing whatever the real result or error was.

Happy to test patches or narrow this further.

Activity

  1. jackkav commented on Aug 14, 2026

    @jackkav
    Author

    Follow-up: I tried to narrow this to a patch and got as far as "it is not quickjs's C logic — it is specific to the WASM build". Sharing the negative results so nobody repeats them, plus two build discrepancies that look worth a look.

    The same C source is clean natively

    I downloaded the exact vendored tree (vendor/quickjs, VERSION = 2025-09-13) and built a minimal native harness that mirrors the shim's call sequence — JS_NewRuntime → JS_NewContext → JS_Eval → while (JS_ExecutePendingJob(rt, &pctx) == 1) → JS_FreeContext → JS_FreeRuntime:

    JSRuntime *rt = JS_NewRuntime();
    JSContext *ctx = JS_NewContext(rt);
    JSValue v = JS_Eval(ctx, code, strlen(code), "<in>", JS_EVAL_TYPE_GLOBAL);
    JS_FreeValue(ctx, v);
    JSContext *pctx; int st;
    while ((st = JS_ExecutePendingJob(rt, &pctx)) == 1) { }
    JS_FreeContext(ctx);
    JS_FreeRuntime(rt);   // never aborts

    Clean at 200k and 500k objects, with asserts verified live (NDEBUG not defined). Also clean:

    variation result
    -O1, -O2, -Oz, -Oz -flto clean
    with and without -fwrapv clean
    -DCONFIG_STACK_CHECK defined clean
    JS_NewContextRaw + exactly DefaultIntrinsics' subset (no RegExpCompiler, no WeakRef) instead of JS_NewContext clean
    JS_SetMaxStackSize small enough to force a stack-overflow throw throws, but still frees clean — no leak
    current bellard/quickjs master (2026-06-04) via the qjs CLI clean

    So: the vendored engine's GC, the -fwrapv/LTO/optimization axis, the raw-context intrinsic subset, and stack-overflow error paths are all ruled out as causes. Meanwhile the WASM build aborts at 100k on wasmfile-release-sync, wasmfile-debug-sync, ng-wasmfile-release-sync and ng-wasmfile-debug-sync — i.e. across both engines and both optimization levels — which also rules out "release-only miscompilation".

    I also read QTS_ExecutePendingJob, QTS_NewRuntime and QTS_NewContext in c/interface.c and could not find a leaked JSValue: the success path returns jsvalue_to_heap(JS_NewFloat64(...)) (not a GC object) and the exception path's handle is disposed by DisposableFail on the JS side.

    Two build discrepancies

    Neither is confirmed as the cause — I could not get an emscripten toolchain running locally to test them — but both are places where the WASM build diverges from what quickjs expects:

    1. -DCONFIG_STACK_CHECK is force-enabled, which quickjs explicitly disables for emscripten. In vendor/quickjs/quickjs.c:

    #if !defined(EMSCRIPTEN)
    /* enable stack limitation */
    #define CONFIG_STACK_CHECK
    #endif

    but packages/variant-quickjs-wasmfile-release-sync/Makefile has:

    QUICKJS_DEFINES:=-D_GNU_SOURCE -DCONFIG_VERSION=\"$(QUICKJS_CONFIG_VERSION)\" -DCONFIG_STACK_CHECK

    Because the -D wins, stack checking is active in the WASM builds against upstream's intent. That matters for this bug specifically because rt->stack_top is captured wherever JS_NewRuntime is called, while jobs run from a different host→WASM entry point (QTS_ExecutePendingJob) — so the frame address compared against stack_limit during a job need not be commensurable with the one recorded at runtime creation. That is also the only mechanism I found that would naturally distinguish "inside a promise job" from "synchronous", which is this bug's sharpest discriminator.

    The cheap test, for anyone with the toolchain: drop -DCONFIG_STACK_CHECK from the variant Makefiles and re-run the reproducer.

    2. js_def_malloc_usable_size returns 0 under emscripten, so malloc_size accounting only ever adds MALLOC_OVERHEAD per allocation:

    #elif defined(EMSCRIPTEN)
        return 0;

    That degrades js_trigger_gc's threshold logic in WASM only, which would explain why the abort is allocation-volume dependent in WASM and not natively. On its own it should only change GC timing, not correctness — but it does mean GC runs at different points in the WASM build than in any native build, so it is a plausible enabler rather than the fault itself.

    Environment

    macOS darwin arm64, Node v24.14.0, clang from Xcode; quickjs-emscripten 0.32.0; vendored quickjs 2025-09-13.

    I don't have a working emscripten setup to go further. Happy to run any experiment against the published WASM builds, or to test a patched variant if someone can publish one.

  2. jackkav commented on Aug 17, 2026

    @jackkav
    Author

    Follow-up that should save someone a wrong turn: there are two distinct failure modes behind this assertion, and runtime.setMaxStackSize() fixes one of them and not the other. My original report is only about the second.

    Measured on @jitl/quickjs-wasmfile-release-sync 0.32.0, each row a fresh process:

    trigger setMaxStackSize(256 * 1024) result
    unbounded guest recursion, inside a job absent host RangeError: Maximum call stack size exceeded out of executePendingJobs, then the gc_obj_list abort on dispose
    unbounded guest recursion, top-level (no job) absent host RangeError escapes evalCode and kills the process before dispose is even reached
    unbounded guest recursion, either present QuickJS's own InternalError — catchable by the guest — and a clean dispose
    ~200k objects allocated inside a promise job absent abort
    ~200k objects allocated inside a promise job present still aborts

    So:

    1. The recursion mode is a stack-check calibration problem, and it is host-fixable. js_check_stack_overflow probes __builtin_frame_address(0) against rt->stack_limit. Under emscripten that address lives in linear memory, while guest recursion actually consumes the WASM call stack, which the probe can't see — so with the default JS_DEFAULT_STACK_SIZE (1MB) the check under-counts and the real WASM stack (-s STACK_SIZE=5MB) blows first. Note quickjs.c deliberately opts out of this on emscripten:

    #if !defined(EMSCRIPTEN)
    /* enable stack limitation */
    #define CONFIG_STACK_CHECK
    #endif

    but the variant Makefiles force it back on:

    QUICKJS_DEFINES:=-D_GNU_SOURCE -DCONFIG_VERSION=\"$(QUICKJS_CONFIG_VERSION)\" -DCONFIG_STACK_CHECK

    Calling setMaxStackSize(256 * 1024) shrinks stack_limit enough that the under-counting probe trips before the real stack does, which is why it works — it is compensating for a miscalibrated measurement rather than measuring the right thing. Might be worth either not forcing -DCONFIG_STACK_CHECK for emscripten builds, or shipping a WASM-appropriate default stack size so consumers don't each have to discover this constant. (We've taken the setMaxStackSize route downstream in the meantime.)

    2. The allocation mode in this issue is not stack-related at all. The guest script completes normally — __k = 200000, no InternalError, no host RangeError — and only dispose() aborts. setMaxStackSize makes no difference at any value I tried. This also retracts the -DCONFIG_STACK_CHECK suspicion I raised earlier in this issue: a correctly-firing stack check doesn't help here, and nothing in this reproducer approaches any stack limit.

    Also, for whoever picks this up: js_def_malloc_usable_size returns 0 under emscripten, so malloc_size/malloc_count accounting — and therefore GC trigger timing — is degraded in WASM builds only. That's still my best guess for why this mode is allocation-volume dependent in WASM and never reproduces natively (the identical vendored source built natively is clean at 500k objects, at every optimization level, with and without -fwrapv, and with CONFIG_STACK_CHECK defined). Unverified — I don't have an -sASSERTIONS/DUMP_LEAKS build to confirm it.

  3. amedeorizzo commented on Sep 21, 2026

    @amedeorizzo

    Possible mitigation, and a correction to one point in the report: on the sync WASM build the abort is catchable, and the result is recoverable.

    The issue says it "cannot be caught and reported as a script failure" and that it "escapes context.dispose() in a finally block, replacing whatever the real result or error was". On @jitl/quickjs-singlefile-cjs-release-sync@0.32.0 it surfaces as a catchable JS exception thrown out of context.dispose(), not a process-level abort — so wrapping the dispose, and reading the value out before disposing, returns the correct result every time.

    let value = null;
    try {
      // ... evalCode, executePendingJobs ...
      const h = vm.getProp(vm.global, "__k");
      value = vm.dump(h);        // read BEFORE dispose
      h.dispose();
    } finally {
      try { vm.dispose(); } catch { /* contained */ }
    }

    Measured with your 200k reproducer, fresh newContext() per round on one shared module:

    rounds=20  aborted=1 (round 1)  correct=20  wrong=0     x3 runs, identical
    module still usable afterwards: yes
    

    Two things that might be useful for narrowing it:

    1. It is the FIRST disposal only. Across 3 runs of 20 rounds, the abort always fired on round 1 and never again, on the same shared QuickJSWASMModule. Rounds 2-20 disposed cleanly. That is consistent with the emscripten allocator reusing the half-freed runtime slot, and it fits the "GC timing differs in WASM" hypothesis in the first comment better than a retention explanation.
    2. The module is not poisoned. Subsequent newContext() calls work normally and results stay correct, so for a long-lived process the cost is one abort per process, not one per script. That may be the difference between a crashed worker and a log line.

    Caveats, so nobody over-reads this: measured on singlefile-cjs-release-sync, not the wasmfile variant in the reproducer, and on the sync (non-asyncify) build. It does not fix the leak — the runtime is still left un-freeable, and the [js_context] leak is still reported by TestQuickJSWASMModule on the debug variant. It only stops the abort from destroying an already-computed result.

    We run user-parameterised fetch/transform scripts in this sandbox and hit exactly the shape you describe — paginated await fetch(...) followed by JSON.parse of a few MB, which is allocation inside a job by construction. Containing the dispose was enough to make it a non-event for us.

  4. xfy2412 commented on Sep 22, 2026

    @xfy2412

    Root cause found — it is in the host layer, not in the engine, and it is deterministic. (Reported by
    us downstream; we can reproduce on 0.32.0 with the release asyncify variants of both engines.)

    Minimal repro (32 MiB memory limit, 512 KiB stack limit — but neither limit matters):

    const rt = QuickJS.newRuntime()
    rt.setMemoryLimit(32 * 1024 * 1024)
    rt.setMaxStackSize(512 * 1024)
    const vm = rt.newContext()
    vm.evalCode(`Promise.resolve().then(() => {
      const a = []; for (let i = 0; i < 200000; i++) a.push({ i });
    })`)
    rt.executePendingJobs()
    vm.dispose()
    rt.dispose()
    // Aborted(Assertion failed: list_empty(&rt->gc_obj_list), at: quickjs.c, JS_FreeRuntime)

    Sweep (release-asyncify, stock 0.32.0 host code):

    objects bellard quickjs-ng
    120k clean clean
    160k abort abort
    200k / 300k / 500k abort abort

    Mechanism:

    1. executePendingJobs creates the out-parameter buffer and its typed-array view before calling
      QTS_ExecutePendingJob.
    2. Running the job grows the WASM memory (16 MiB → 23 MiB here), Emscripten swaps the ArrayBuffer,
      and the pre-call view becomes detached: typedArray[0] reads undefined, not 0.
    3. ctxPtr === 0 misses undefined ⇒ contextMap.get(undefined) misses ⇒ the fallback
      this.newContext({ contextPointer: undefined }) runs.
    4. On asyncify runtimes, QuickJSAsyncRuntime.newContext ignores options.contextPointer and calls
      QTS_NewContext, so a new engine context is created and never owned — it is still in
      gc_obj_list when JS_FreeRuntime asserts. (The base QuickJSRuntime does honor the option;
      that inconsistency is fix: honor options.contextPointer in QuickJSAsyncRuntime.newContext #272.)

    So this is one chain with two independent halves:

    Things that do not help, all measured: switching to quickjs-ng (same threshold, same assertion —
    consistent with a host-layer cause), disabling -DCONFIG_STACK_CHECK / passing a 0 stack limit, and
    forcing an extra GC before disposal. A native build is clean because nothing gets detached there.

    If it is useful I can share the reproducer and the instrumented build (a -DDUMP_LEAKS variant whose
    leak dump names the leaked js_context, plus a tagged commit of the instrumentation) — just say where
    to put it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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