Skip to content

setMemoryLimit bounds a single allocation, not a runtime's total — large strings and typed arrays never accumulate #271

Description

@josefguenther

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:

  1. whether malloc_size (as opposed to memory_used_size, the only one computeMemoryUsage() exposes here) advances for large-block allocations at all;
  2. 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.

Activity

  1. josefguenther commented on Sep 3, 2026

    @josefguenther
    Author

    Root cause found — it is upstream in cutils.h, and it is not large-block-specific

    Follow-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_ONLY

    That 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_funcs installs it, and js_malloc_rt does:

    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/8 simultaneous allocations, each
    individually 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-wasi falls 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 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_size for 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 declares malloc_usable_size in
    malloc.h and defines it in libc.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 MB ran to 1022 MB stops at 31 MB, heap tops out at 32.19 MB

    On computeMemoryUsage() reporting the total accurately

    Both are true and they are different fields. JS_ComputeMemoryUsage walks the heap and adds
    abuf->byte_length and string bytes into memory_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, since setMemoryLimit currently provides no byte bound on any emscripten build —
    your call whether that is worth carrying or whether tracking the upstream fix is enough.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions