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

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.

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