Repository navigation
list_empty(&rt->gc_obj_list) assertion on dispose after allocating many objects inside a promise job (minimal repro, no host functions) #269
Description
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() // 💥 abortAborted(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 ofcontext.resolvePromise()(so no hostnewFunctioncallbacks 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/*all0.32.0(currentlatest)- 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
jackkav commentedon Aug 14, 2026
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:Clean at 200k and 500k objects, with asserts verified live (
NDEBUGnot defined). Also clean:-O1,-O2,-Oz,-Oz -flto-fwrapv-DCONFIG_STACK_CHECKdefinedJS_NewContextRaw+ exactlyDefaultIntrinsics' subset (noRegExpCompiler, noWeakRef) instead ofJS_NewContextJS_SetMaxStackSizesmall enough to force a stack-overflow throwbellard/quickjsmaster (2026-06-04) via theqjsCLISo: 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 onwasmfile-release-sync,wasmfile-debug-sync,ng-wasmfile-release-syncandng-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_NewRuntimeandQTS_NewContextinc/interface.cand could not find a leakedJSValue: the success path returnsjsvalue_to_heap(JS_NewFloat64(...))(not a GC object) and the exception path's handle is disposed byDisposableFailon 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_CHECKis force-enabled, which quickjs explicitly disables for emscripten. Invendor/quickjs/quickjs.c:but
packages/variant-quickjs-wasmfile-release-sync/Makefilehas:Because the
-Dwins, stack checking is active in the WASM builds against upstream's intent. That matters for this bug specifically becausert->stack_topis captured whereverJS_NewRuntimeis called, while jobs run from a different host→WASM entry point (QTS_ExecutePendingJob) — so the frame address compared againststack_limitduring 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_CHECKfrom the variant Makefiles and re-run the reproducer.2.
js_def_malloc_usable_sizereturns0under emscripten, somalloc_sizeaccounting only ever addsMALLOC_OVERHEADper allocation: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, Nodev24.14.0, clang from Xcode;quickjs-emscripten0.32.0; vendored quickjs2025-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.
fix(quickjs): survive the WASM teardown abort instead of losing the run
fix(quickjs): survive the WASM teardown abort instead of losing the run
feat(quickjs): insomnia.sendRequest() bridge for the QuickJS script s…
jackkav commentedon Aug 17, 2026
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-sync0.32.0, each row a fresh process:setMaxStackSize(256 * 1024)RangeError: Maximum call stack size exceededout ofexecutePendingJobs, then thegc_obj_listabort on disposeRangeErrorescapesevalCodeand kills the process before dispose is even reachedInternalError— catchable by the guest — and a clean disposeSo:
1. The recursion mode is a stack-check calibration problem, and it is host-fixable.
js_check_stack_overflowprobes__builtin_frame_address(0)againstrt->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 defaultJS_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:but the variant Makefiles force it back on:
Calling
setMaxStackSize(256 * 1024)shrinksstack_limitenough 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_CHECKfor emscripten builds, or shipping a WASM-appropriate default stack size so consumers don't each have to discover this constant. (We've taken thesetMaxStackSizeroute downstream in the meantime.)2. The allocation mode in this issue is not stack-related at all. The guest script completes normally —
__k = 200000, noInternalError, no hostRangeError— and onlydispose()aborts.setMaxStackSizemakes no difference at any value I tried. This also retracts the-DCONFIG_STACK_CHECKsuspicion 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_sizereturns0under emscripten, somalloc_size/malloc_countaccounting — 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 withCONFIG_STACK_CHECKdefined). Unverified — I don't have an-sASSERTIONS/DUMP_LEAKSbuild to confirm it.amedeorizzo commentedon Sep 21, 2026
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 afinallyblock, replacing whatever the real result or error was". On@jitl/quickjs-singlefile-cjs-release-sync@0.32.0it surfaces as a catchable JS exception thrown out ofcontext.dispose(), not a process-level abort — so wrapping the dispose, and reading the value out before disposing, returns the correct result every time.Measured with your 200k reproducer, fresh
newContext()per round on one shared module:Two things that might be useful for narrowing it:
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.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 thewasmfilevariant 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 byTestQuickJSWASMModuleon 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 byJSON.parseof a few MB, which is allocation inside a job by construction. Containing the dispose was enough to make it a non-event for us.xfy2412 commentedon Sep 22, 2026
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):
Sweep (release-asyncify, stock 0.32.0 host code):
Mechanism:
executePendingJobscreates the out-parameter buffer and its typed-array view before callingQTS_ExecutePendingJob.ArrayBuffer,and the pre-call view becomes detached:
typedArray[0]readsundefined, not0.ctxPtr === 0missesundefined⇒contextMap.get(undefined)misses ⇒ the fallbackthis.newContext({ contextPointer: undefined })runs.QuickJSAsyncRuntime.newContextignoresoptions.contextPointerand callsQTS_NewContext, so a new engine context is created and never owned — it is still ingc_obj_listwhenJS_FreeRuntimeasserts. (The baseQuickJSRuntimedoes honor the option;that inconsistency is fix: honor options.contextPointer in QuickJSAsyncRuntime.newContext #272.)
So this is one chain with two independent halves:
that produces your abort; and
newContextoverride droppingcontextPointer(fix: honor options.contextPointer in QuickJSAsyncRuntime.newContext #272), which is what turns a badpointer into a leaked context instead of a wrong-but-wrapped one. Note fix: honor options.contextPointer in QuickJSAsyncRuntime.newContext #272 alone does not fix
this issue:
undefined || QTS_NewContext(...)still creates a context.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, andforcing 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_LEAKSvariant whoseleak dump names the leaked
js_context, plus a tagged commit of the instrumentation) — just say whereto put it.