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

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