You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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'importvariantfrom'@jitl/quickjs-wasmfile-release-sync'constQuickJS=awaitnewQuickJSWASMModuleFromVariant(variant)constvm=QuickJS.newContext()// A plain promise job. No host function, no await, no bridge.constr=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()consth=vm.getProp(vm.global,'__k')console.log('__k =',vm.dump(h))// 200000 — the script ran fineh.dispose()vm.dispose()// 💥 abort
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:
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
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.
Allocating many objects inside a promise job leaves the runtime un-freeable:
context.dispose()aborts the WASM module onassert(list_empty(&rt->gc_obj_list))inJS_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, nonewPromise, no memory limit, no async host work. Just VM-side allocation in a job, then dispose.Reproducer
The same thing written with an
asyncIIFE +awaitaborts identically, so it is not specific to async functions:Affects both C engines
Reproduced at
0.32.0on both, which suggests the glue/FFI layer rather than one engine:@jitl/quickjs-wasmfile-release-syncAborted(… list_empty(&rt->gc_obj_list), at: ../../vendor/quickjs/quickjs.c,2036,JS_FreeRuntime)@jitl/quickjs-wasmfile-debug-sync@jitl/quickjs-ng-wasmfile-release-syncAborted(… list_empty(&rt->gc_obj_list), at: ../../vendor/quickjs-ng/quickjs-amalgam.c,12520,JS_FreeRuntime)@jitl/quickjs-ng-wasmfile-debug-syncWhat narrows it
All at 200k iterations, each run in a fresh process:
a = nullbefore the job returnsSo 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 abortingdispose(), for the 200k-object.thenreproducer 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_listis 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-sASSERTIONSto dump the list contents.Ruled out (host-side)
None of these change the outcome:
runtime.setMemoryLimit()at all, or raising it to 512MBruntime.setInterruptHandler()context.getPromiseState()instead ofcontext.resolvePromise()(so no hostnewFunctioncallbacks are installed at all)executePendingJobs()passes after the job completesruntime.computeMemoryUsage()before dispose (per the suggestion on Reproducible list_empty assertion failure #235)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)v24.14.0,darwin arm64Why 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 nativeabort()rather than a thrown error, it cannot be caught and reported as a script failure. The abort escapescontext.dispose()in afinallyblock, replacing whatever the real result or error was.Happy to test patches or narrow this further.