From 96e9833941107a56cd1b9e00856a9ce100da0814 Mon Sep 17 00:00:00 2001 From: jackkav Date: Fri, 16 Oct 2026 16:02:28 +0200 Subject: [PATCH] Add maxMemoryBytes: a hard cap on WebAssembly linear memory newVariant(variant, { maxMemoryBytes }) instantiates the module with a WebAssembly.Memory whose maximum is the cap. setMemoryLimit depends on QuickJS's allocation accounting, which undercounts on Emscripten builds; the cap does not. At the cap, allocation fails inside the module and guest code gets a catchable InternalError, the runtime disposes cleanly, and the heap never exceeds it. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 9 ++ .../src/from-variant.ts | 32 +++++ .../quickjs-emscripten/src/memory-cap.test.ts | 126 ++++++++++++++++++ 3 files changed, 167 insertions(+) create mode 100644 packages/quickjs-emscripten/src/memory-cap.test.ts diff --git a/README.md b/README.md index c0376601c..c8cc0a8e0 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,15 @@ context.dispose() runtime.dispose() ``` +`setMemoryLimit` relies on QuickJS's own allocation accounting, which can undercount in WebAssembly +builds. For a hard guarantee, cap the module's linear memory (release variants only; at least 16 MiB). +Allocation then fails inside the module and guest code gets a catchable `InternalError`: + +```typescript +const variant = newVariant(RELEASE_SYNC, { maxMemoryBytes: 64 * 1024 * 1024 }) +const QuickJS = await newQuickJSWASMModuleFromVariant(variant) +``` + #### EcmaScript Module Exports When you evaluate code as an ES Module, the result will be a handle to the diff --git a/packages/quickjs-emscripten-core/src/from-variant.ts b/packages/quickjs-emscripten-core/src/from-variant.ts index e04228775..3123e5d5b 100644 --- a/packages/quickjs-emscripten-core/src/from-variant.ts +++ b/packages/quickjs-emscripten-core/src/from-variant.ts @@ -114,6 +114,11 @@ function smartUnwrap(val: T | { default: T } | { default: { de return val as T } +const WASM_PAGE_BYTES = 64 * 1024 +const WASM_MAX_PAGES = 65536 +/** Emscripten's default INITIAL_MEMORY (16 MiB); the variants do not override it. */ +const EMSCRIPTEN_INITIAL_PAGES = 256 + export type OrLoader = T | (() => Promise) export interface CustomizeVariantOptions { @@ -125,6 +130,19 @@ export interface CustomizeVariantOptions { wasmModule?: OrLoader /** If given, use the Memory when instantiating the WebAssembly.Instance. */ wasmMemory?: OrLoader + /** + * Hard cap, in bytes, on this module's WebAssembly linear memory (rounded down to 64 KiB pages). + * When the cap is reached allocations fail inside the module: guest code gets a catchable + * `InternalError` and the host stays within the cap. + * + * Unlike {@link QuickJSRuntime#setMemoryLimit} this does not depend on QuickJS's own allocation + * accounting, which can undercount on Emscripten builds. It bounds *everything* in the module: + * the guest heap, host-side buffers, and the 5 MiB WASM stack, so it must be at least 16 MiB. + * + * Release variants only: debug (sanitizer) variants abort the module when the heap cannot grow. + * Cannot be combined with {@link wasmMemory}. + */ + maxMemoryBytes?: number /** If given, Emscripten will try to load the source map for the WebAssembly module from this location (path or URI) as appropriate for the current platform. */ wasmSourceMapLocation?: string /** If given, we will provide the source map to Emscripten directly. This may only be respected if wasmModule is also provided. */ @@ -227,7 +245,21 @@ export function newVariant( } if (options.wasmMemory) { + if (options.maxMemoryBytes !== undefined) { + throw new TypeError("newVariant: pass either wasmMemory or maxMemoryBytes, not both") + } moduleLoaderArg.wasmMemory = await force(options.wasmMemory) + } else if (options.maxMemoryBytes !== undefined) { + const maximumPages = Math.floor(options.maxMemoryBytes / WASM_PAGE_BYTES) + if (!(maximumPages >= EMSCRIPTEN_INITIAL_PAGES)) { + throw new RangeError( + `maxMemoryBytes must be at least ${EMSCRIPTEN_INITIAL_PAGES * WASM_PAGE_BYTES} (16 MiB)`, + ) + } + moduleLoaderArg.wasmMemory = new WebAssembly.Memory({ + initial: EMSCRIPTEN_INITIAL_PAGES, + maximum: Math.min(maximumPages, WASM_MAX_PAGES), + }) } const optionsWasmModule = options.wasmModule diff --git a/packages/quickjs-emscripten/src/memory-cap.test.ts b/packages/quickjs-emscripten/src/memory-cap.test.ts new file mode 100644 index 000000000..d2d8624fa --- /dev/null +++ b/packages/quickjs-emscripten/src/memory-cap.test.ts @@ -0,0 +1,126 @@ +import assert from "assert" +import { describe, it } from "vitest" +import NG_RELEASE_ASYNC from "@jitl/quickjs-ng-wasmfile-release-asyncify" +import NG_RELEASE_SYNC from "@jitl/quickjs-ng-wasmfile-release-sync" +import { + newQuickJSAsyncWASMModuleFromVariant, + newQuickJSWASMModuleFromVariant, + newVariant, + RELEASE_ASYNC, + RELEASE_SYNC, +} from "." + +/** + * `maxMemoryBytes` is a hard cap on WebAssembly linear memory. Unlike `setMemoryLimit` it does not + * rely on QuickJS counting allocations. Release variants only: the debug (sanitizer) builds abort + * the whole module when the heap cannot grow, so exhaustion is not survivable there. + */ +const MiB = 1024 * 1024 +const CAP = 64 * MiB + +// Allocates ~1 MiB distinct strings until something gives, reporting heap size to the host each time. +const ALLOCATE_UNTIL_FAILURE = (caught: boolean) => ` + const kept = [] + ${caught ? "try {" : ""} + for (let i = 0; i < 400; i++) { + kept.push(("x" + i).repeat(262144)) + heapBytes() + } + ${caught ? `} catch (e) { globalThis.caught = e.name + ": " + e.message }` : ""} + kept.length +` + +const sync = { name: "quickjs sync", base: RELEASE_SYNC, create: newQuickJSWASMModuleFromVariant } +const ng = { + name: "quickjs-ng sync", + base: NG_RELEASE_SYNC, + create: newQuickJSWASMModuleFromVariant, +} +const async = { + name: "quickjs asyncify", + base: RELEASE_ASYNC, + create: newQuickJSAsyncWASMModuleFromVariant, +} +const ngAsync = { + name: "quickjs-ng asyncify", + base: NG_RELEASE_ASYNC, + create: newQuickJSAsyncWASMModuleFromVariant, +} + +for (const { name, base, create } of [sync, ng, async, ngAsync]) { + describe(`maxMemoryBytes (${name})`, () => { + async function setup(options: { memoryLimit?: number } = {}) { + const module = await (create as typeof newQuickJSWASMModuleFromVariant)( + newVariant(base as any, { maxMemoryBytes: CAP }) as any, + ) + const memory = module.getWasmMemory() + const runtime = module.newRuntime() + if (options.memoryLimit) runtime.setMemoryLimit(options.memoryLimit) + const vm = runtime.newContext() + let peakBytes = memory.buffer.byteLength + vm.newFunction("heapBytes", () => { + peakBytes = Math.max(peakBytes, memory.buffer.byteLength) + }).consume((fn) => vm.setProp(vm.global, "heapBytes", fn)) + return { memory, runtime, vm, peak: () => Math.max(peakBytes, memory.buffer.byteLength) } + } + + it("never grows linear memory past the cap, and guest code can catch the failure", async () => { + const { memory, runtime, vm, peak } = await setup() + const result = vm.unwrapResult(vm.evalCode(ALLOCATE_UNTIL_FAILURE(true))) + const kept = vm.getNumber(result) + result.dispose() + + assert.ok(kept < 400, `allocation should have failed before 400 MiB (kept ${kept})`) + assert.match(vm.dump(vm.getProp(vm.global, "caught")) as string, /^InternalError: .*memory/) + assert.ok(peak() <= CAP, `heap peaked at ${peak()} bytes, cap is ${CAP}`) + assert.ok(memory.buffer.byteLength <= CAP) + + // Still usable, and teardown does not trip QuickJS's list_empty(gc_obj_list) assertion. + assert.strictEqual(vm.getNumber(vm.unwrapResult(vm.evalCode(`1 + 1`))), 2) + vm.dispose() + runtime.dispose() + }) + + it("surfaces an uncaught failure as a guest error, not a crash", async () => { + const { runtime, vm, peak } = await setup() + const result = vm.evalCode(ALLOCATE_UNTIL_FAILURE(false)) + assert.ok(result.error, "expected the allocation to fail") + assert.strictEqual(vm.dump(result.error).name, "InternalError") + result.error.dispose() + assert.ok(peak() <= CAP) + vm.dispose() + runtime.dispose() + }) + + it("holds even when setMemoryLimit is far larger than the cap", async () => { + const { runtime, vm, peak } = await setup({ memoryLimit: 1024 * MiB }) + const result = vm.evalCode(ALLOCATE_UNTIL_FAILURE(true)) + if (result.error) result.error.dispose() + else result.value.dispose() + assert.ok(peak() <= CAP, `heap peaked at ${peak()} bytes, cap is ${CAP}`) + vm.dispose() + runtime.dispose() + }) + }) +} + +describe("maxMemoryBytes validation", () => { + it("rejects caps below the 16 MiB initial memory", async () => { + await assert.rejects( + newQuickJSWASMModuleFromVariant(newVariant(RELEASE_SYNC, { maxMemoryBytes: 8 * MiB })), + RangeError, + ) + }) + + it("rejects combining maxMemoryBytes with wasmMemory", async () => { + await assert.rejects( + newQuickJSWASMModuleFromVariant( + newVariant(RELEASE_SYNC, { + maxMemoryBytes: CAP, + wasmMemory: new WebAssembly.Memory({ initial: 256, maximum: 1024 }), + }), + ), + TypeError, + ) + }) +})