Repository navigation
setMemoryLimit bounds a single allocation, not a runtime's total — large strings and typed arrays never accumulate #271
Description
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-emscripten0.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 againstmalloc_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 tomemory_used_size, the only onecomputeMemoryUsage()exposes here) advances for large-block allocations at all; - whether the host-
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.
Activity
josefguenther commentedon Sep 3, 2026
Root cause found — it is upstream in
cutils.h, and it is not large-block-specificFollow-up on my own report. I traced this and two claims in my original write-up were
wrong, so I want to correct them before anyone acts on the hypothesis.
It is not
JS_MALLOC_LARGE_BLOCKS_ONLYThat macro does not appear in quickjs-ng 0.12.1 or 0.16.2 at all. The defect is in
cutils.h, in code both engine lineages share:def_malloc_funcsinstalls it, andjs_malloc_rtdoes:The check reads the real requested size; the accumulator advances by a constant 8 bytes
per allocation (
MALLOC_OVERHEAD, 8 on every platform but Apple). That single asymmetryexplains every row in my table: one allocation above the limit is refused because the check
is honest, and no number of sub-limit allocations ever accumulates because the accounting
is not.
It is not "large blocks bypass accounting while the object heap is bounded"
This is the part I got wrong. Nothing is byte-accounted on these platforms. The object
loop stops not because bytes were tracked but because it exhausts a count budget: at 8
bytes per allocation, an 8 MB limit permits ~1,048,576 allocations, and ~311k small objects
is roughly that many allocations once each object's own slots and strings are counted.
The limit has degenerated into "at most
limit/8simultaneous allocations, eachindividually smaller than
limit" — a count budget wearing a byte budget's name.Confirming evidence: 200,000 × 600-byte strings — not "large blocks" under any definition —
also never stop, reaching 114 MB live under an 8 MB limit.
Measured independently of emscripten
I reproduced all of this on a wasm32-wasi build of quickjs-ng compiled directly with
wasi-sdk 25, no emscripten anywhere.
wasm32-wasifalls to the same#else, and theresults match yours row for row — including the small-object loop stopping at exactly
311,073, which is what identifies the shared branch rather than a packaging difference.
So this reproduces on both engine lineages and outside emscripten entirely. It is a
platform-support gap in upstream
cutils.h.0.16.2 is partially fixed, by accident of a different change
ng 0.16.2's arena byte-accounts allocations ≤ 512 B from its own size class and only falls
back to the broken
js__malloc_usable_sizefor larger ones. The tell is that itssmall-object loop stops at 115,726 rather than 311,073 — genuinely byte-driven now.
Bulk allocations are still unbounded: 60 × 1 MB under an 8 MB limit still all succeed.
The fix, verified by rebuild
Add
__wasi__(and, for this package, emscripten) to the branch, and to the<malloc.h>include selector at the top of the same file. wasi-libc declares
malloc_usable_sizeinmalloc.hand defines it inlibc.a; emscripten's musl provides it too.I patched a local copy and rebuilt with an otherwise-identical toolchain and build line.
Every row becomes correct:
push(new Uint8Array(1MB))under 32 MBOn
computeMemoryUsage()reporting the total accuratelyBoth are true and they are different fields.
JS_ComputeMemoryUsagewalks the heap and addsabuf->byte_lengthand string bytes intomemory_used_size, which stays accuratethroughout — 60.01 MB for 60 × 1 MB, 1022 MB for the runaway. Enforcement compares against
malloc_size, which is the field that never advances. The accounting genuinely does seewhat the enforcement ignores; they simply are not the same counter.
Suggested disposition
The fix belongs in quickjs-ng (and bellard/quickjs, which shares the file). I am opening a PR
upstream. Leaving this issue open in case you would rather patch the vendored copy in the
meantime, since
setMemoryLimitcurrently provides no byte bound on any emscripten build —your call whether that is worth carrying or whether tracking the upstream fix is enough.
Fix JS_SetMemoryLimit byte accounting on WASI
Fix JS_SetMemoryLimit byte accounting on WASI