Repository navigation
setMemoryLimit bounds a single allocation, not a runtime's total — large strings and typed arrays never accumulate #271
Description
Activity
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:static inline size_t js__malloc_usable_size(const void *ptr) { #if defined(__APPLE__) return malloc_size(ptr); #elif defined(_WIN32) return _msize((void *)ptr); #elif defined(__linux__) || defined(__ANDROID__) || defined(__CYGWIN__) \ || defined(__FreeBSD__) || defined(__GLIBC__) return malloc_usable_size((void *)ptr); #else return 0; // ← emscripten (musl) lands here #endif }
def_malloc_funcsinstalls it, andjs_malloc_rtdoes:if (unlikely(s->malloc_size + size > s->malloc_limit - 1)) return NULL; // checks the REQUESTED size s->malloc_size += rt->mf.js_malloc_usable_size(ptr) + MALLOC_OVERHEAD; // adds 0 + 8
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 asymmetry
explains 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, each
individually smaller thanlimit" — 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 the
results 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 upstreamcutils.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 brokenjs__malloc_usable_sizefor larger ones. The tell is that its
small-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 declaresmalloc_usable_sizein
malloc.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:probe (8 MB limit) before after 1 × 7 MB Uint8Array allowed allowed 1 × 8 MB Uint8Array refused refused 2 × 7 MB Uint8Array allowed (14 MB live) refused 60 × 1 MB Uint8Array all allowed (60 MB) refused 200k × 600-byte strings no stop, 114 MB refused runaway push(new Uint8Array(1MB))under 32 MBran to 1022 MB stops at 31 MB, heap tops out at 32.19 MB On
computeMemoryUsage()reporting the total accuratelyBoth are true and they are different fields.
JS_ComputeMemoryUsagewalks the heap and adds
abuf->byte_lengthand string bytes intomemory_used_size, which stays accurate
throughout — 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 see
what 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, sincesetMemoryLimitcurrently provides no byte bound on any emscripten build —
your call whether that is worth carrying or whether tracking the upstream fix is enough.- added a commit that references this issue
on Sep 13, 2026 - added a commit that references this issue
on Sep 13, 2026
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.