TL;DR
runtime.setMemoryLimit(N) refuses a single allocation larger than N, but the running total it compares against never advances for large strings or typed-array backing stores. So any number of allocations each individually under N all succeed, and a runtime can reach 2 GB under a 32 MB limit.
The ordinary object heap is bounded correctly, and computeMemoryUsage() reports the oversized total accurately — so the accounting appears to see these allocations even though the enforcement does not act on them.
Versions
quickjs-emscripten 0.32.0 (latest published; released 2026-02-16)
- Reproduced on all four published variants tried:
@jitl/quickjs-ng-wasmfile-release-sync, @jitl/quickjs-ng-wasmfile-release-asyncify, and @jitl/quickjs-wasmfile-release-sync — i.e. both engine lineages, sync and asyncify alike, all at 0.32.0
- Host: Cloudflare
workerd (via @cloudflare/vitest-pool-workers); not host-specific as far as we can tell
Reproduction
import { newQuickJSWASMModuleFromVariant, newVariant } from "quickjs-emscripten-core"
import baseVariant from "@jitl/quickjs-ng-wasmfile-release-sync"
const M = await newQuickJSWASMModuleFromVariant(newVariant(baseVariant, {}))
function probe(label, body) {
const rt = M.newRuntime()
rt.setMemoryLimit(8 * 1024 * 1024) // 8 MB
const ctx = rt.newContext()
const r = ctx.evalCode(`(function(){${body}})()`)
const out = r.value ? ctx.dump(r.value) : "REFUSED"
;(r.value || r.error).dispose()
const h = rt.computeMemoryUsage()
const usage = ctx.dump(h); h.dispose()
ctx.dispose(); rt.dispose()
console.log(label, "=>", out, "| memory_used_size =",
(usage.memory_used_size / 1048576).toFixed(1) + " MB",
"| malloc_limit =", (usage.malloc_limit / 1048576).toFixed(1) + " MB")
}
probe("one 7 MB Uint8Array ", `const a = new Uint8Array(7*1024*1024); return "ok"`)
probe("one 8 MB Uint8Array ", `const a = new Uint8Array(8*1024*1024); return "ok"`)
probe("two 7 MB Uint8Arrays ", `const a = new Uint8Array(7*1024*1024), b = new Uint8Array(7*1024*1024); return "ok-both"`)
probe("two 7 MB strings ", `const a = "x".repeat(7*1024*1024), b = "y".repeat(7*1024*1024); return "ok-both"`)
probe("60 x 1 MB Uint8Array ", `const a=[]; let n=0; try { for (; n<60; n++) a.push(new Uint8Array(1024*1024)) } catch (e) { return "STOPPED@"+n } return "ALL "+n`)
probe("small objects, no cap ", `const a=[]; let n=0; try { for (; n<400000; n++) a.push({x:n, y:"zzzzzzzzzzzzzzzzzzzz"+n}) } catch (e) { return "STOPPED@"+n } return "ALL "+n`)
Observed
| probe (limit 8 MB) |
result |
one 7 MB Uint8Array |
allowed |
one 8 MB Uint8Array |
REFUSED — the check does run and does read the limit |
two × 7 MB Uint8Array |
both allowed — 14 MB live under an 8 MB limit |
| two × 7 MB strings |
both allowed |
60 × 1 MB Uint8Array |
all 60 allowed — 60 MB |
| loop of small plain objects |
correctly stopped at ~311,073 |
Separately, with a 32 MB limit:
- holding 60 × 1 MB reports
memory_used_size = 60.1 MB against malloc_limit = 32 MB — the reported usage is accurate and already past the limit;
- an unbounded
while (true) a.push(new Uint8Array(1024*1024)) accepts 2,042 allocations and then fails, having grown the WASM heap to 2,048 MB — i.e. it stops at the 32-bit WASM ceiling, not at the configured limit.
What this rules out
- Not the check being absent — a single over-limit allocation is refused, so the comparison executes and reads
malloc_limit.
- Not GC reclaiming between allocations — in the two-allocation probes both bindings are live and referenced when the second succeeds.
- Not variant-specific — identical on the sync and asyncify builds.
- Not all allocation kinds — the ordinary object heap is bounded correctly, so whatever advances the accumulator works for objects and not for bulk string / ArrayBuffer data.
Hypothesis — JS_MALLOC_LARGE_BLOCKS_ONLY
Offered as a hypothesis, but a specific one, because the split we measured has a name in this codebase already.
The comparison looks like malloc_size + size > malloc_limit, with malloc_size failing to advance for large blocks. That explains every row: a single allocation of exactly the limit is refused (the small non-zero baseline tips the sum), one just under is not, and no number of sub-limit allocations ever accumulates.
And it predicts the object-vs-bulk split exactly. PR #266 describes falling back to the host malloc() under __EMSCRIPTEN__ via JS_MALLOC_LARGE_BLOCKS_ONLY, noting this is "exactly as it's already done". If large blocks go to host malloc while smaller allocations stay on quickjs's own allocator, and only the latter feeds malloc_size, then the ordinary object heap is bounded and strings and ArrayBuffer backing stores are not — which is precisely what the table above shows.
Two things we could not check from outside the WASM boundary:
- whether
malloc_size (as opposed to memory_used_size, the only one computeMemoryUsage() exposes here) advances for large-block allocations at all;
- whether the host-
malloc path reports a usable size back into the accumulator, or bypasses it.
Confirmed on both engine lineages
Identical results on @jitl/quickjs-wasmfile-release-sync (bellard) and @jitl/quickjs-ng-wasmfile-release-sync (ng), both 0.32.0 — every row of the table above matches. So this is not a quickjs-ng bug and not a bellard bug; it is in the layer they share. That is also why we have not tried to reproduce against a plain C build: the two cores already disagreeing with the emscripten builds would be the interesting result, and instead they agree with each other.
TL;DR
runtime.setMemoryLimit(N)refuses a single allocation larger thanN, but the running total it compares against never advances for large strings or typed-array backing stores. So any number of allocations each individually underNall succeed, and a runtime can reach 2 GB under a 32 MB limit.The ordinary object heap is bounded correctly, and
computeMemoryUsage()reports the oversized total accurately — so the accounting appears to see these allocations even though the enforcement does not act on them.Versions
quickjs-emscripten0.32.0 (latest published; released 2026-02-16)@jitl/quickjs-ng-wasmfile-release-sync,@jitl/quickjs-ng-wasmfile-release-asyncify, and@jitl/quickjs-wasmfile-release-sync— i.e. both engine lineages, sync and asyncify alike, all at 0.32.0workerd(via@cloudflare/vitest-pool-workers); not host-specific as far as we can tellReproduction
Observed
Uint8ArrayUint8ArrayUint8ArrayUint8ArraySeparately, with a 32 MB limit:
memory_used_size= 60.1 MB againstmalloc_limit= 32 MB — the reported usage is accurate and already past the limit;while (true) a.push(new Uint8Array(1024*1024))accepts 2,042 allocations and then fails, having grown the WASM heap to 2,048 MB — i.e. it stops at the 32-bit WASM ceiling, not at the configured limit.What this rules out
malloc_limit.Hypothesis —
JS_MALLOC_LARGE_BLOCKS_ONLYOffered as a hypothesis, but a specific one, because the split we measured has a name in this codebase already.
The comparison looks like
malloc_size + size > malloc_limit, withmalloc_sizefailing to advance for large blocks. That explains every row: a single allocation of exactly the limit is refused (the small non-zero baseline tips the sum), one just under is not, and no number of sub-limit allocations ever accumulates.And it predicts the object-vs-bulk split exactly. PR #266 describes falling back to the host
malloc()under__EMSCRIPTEN__viaJS_MALLOC_LARGE_BLOCKS_ONLY, noting this is "exactly as it's already done". If large blocks go to hostmallocwhile smaller allocations stay on quickjs's own allocator, and only the latter feedsmalloc_size, then the ordinary object heap is bounded and strings and ArrayBuffer backing stores are not — which is precisely what the table above shows.Two things we could not check from outside the WASM boundary:
malloc_size(as opposed tomemory_used_size, the only onecomputeMemoryUsage()exposes here) advances for large-block allocations at all;mallocpath reports a usable size back into the accumulator, or bypasses it.Confirmed on both engine lineages
Identical results on
@jitl/quickjs-wasmfile-release-sync(bellard) and@jitl/quickjs-ng-wasmfile-release-sync(ng), both 0.32.0 — every row of the table above matches. So this is not a quickjs-ng bug and not a bellard bug; it is in the layer they share. That is also why we have not tried to reproduce against a plain C build: the two cores already disagreeing with the emscripten builds would be the interesting result, and instead they agree with each other.