Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions packages/quickjs-emscripten-core/src/from-variant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,11 @@ function smartUnwrap<T extends object>(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> = T | (() => Promise<T>)

export interface CustomizeVariantOptions {
Expand All @@ -125,6 +130,19 @@ export interface CustomizeVariantOptions {
wasmModule?: OrLoader<WebAssembly.Module>
/** If given, use the Memory when instantiating the WebAssembly.Instance. */
wasmMemory?: OrLoader<WebAssembly.Memory>
/**
* 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. */
Expand Down Expand Up @@ -227,7 +245,21 @@ export function newVariant<T extends QuickJSVariant>(
}

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
Expand Down
126 changes: 126 additions & 0 deletions packages/quickjs-emscripten/src/memory-cap.test.ts
Original file line number Diff line number Diff line change
@@ -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,
)
})
})