From 2bc4b59be766c8b98441cd176f0597f530678f9f Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Mon, 16 Feb 2026 11:59:32 -0500 Subject: [PATCH 1/8] Fix WASM memory view invalidation when memory grows When Emscripten's WASM memory grows (due to -sALLOW_MEMORY_GROWTH), all existing TypedArray views become detached because the underlying ArrayBuffer is replaced. This caused bugs where reading from views after FFI calls returned undefined values if memory had grown. The fix introduces RefreshableTypedArray, a wrapper that lazily recreates the TypedArray view when HEAPU8.buffer changes. This is a simple reference comparison that only triggers view recreation when actually needed. Affected call sites: - runtime.ts: executePendingJobs - reads ctxPtrOut after QTS_ExecutePendingJob - context.ts: newPromise - reads resolve/reject handles after QTS_NewPromiseCapability - context.ts: getLength - reads uint32Out after QTS_GetLength - context.ts: getOwnPropertyNames - reads outPtr and uint32Out after QTS_GetOwnPropertyNames Also fixed: getOwnPropertyNames was using HEAP8.buffer instead of HEAPU8.buffer Co-Authored-By: Claude Opus 4.5 --- .../quickjs-emscripten-core/src/context.ts | 10 ++--- .../quickjs-emscripten-core/src/memory.ts | 41 ++++++++++++++++--- .../quickjs-emscripten-core/src/runtime.ts | 2 +- 3 files changed, 41 insertions(+), 12 deletions(-) diff --git a/packages/quickjs-emscripten-core/src/context.ts b/packages/quickjs-emscripten-core/src/context.ts index 9bc7c8a0d..a4e0323d8 100644 --- a/packages/quickjs-emscripten-core/src/context.ts +++ b/packages/quickjs-emscripten-core/src/context.ts @@ -487,7 +487,7 @@ export class QuickJSContext mutablePointerArray.value.ptr, ) const promiseHandle = this.memory.heapValueHandle(promisePtr) - const [resolveHandle, rejectHandle] = Array.from(mutablePointerArray.value.typedArray).map( + const [resolveHandle, rejectHandle] = Array.from(mutablePointerArray.value.typedArray.value).map( (jsvaluePtr) => this.memory.heapValueHandle(jsvaluePtr as any), ) return new QuickJSDeferredPromise({ @@ -994,7 +994,7 @@ export class QuickJSContext if (status < 0) { return undefined } - return this.uint32Out.value.typedArray[0] + return this.uint32Out.value.typedArray.value[0] } /** @@ -1052,9 +1052,9 @@ export class QuickJSContext if (errorPtr) { return this.fail(this.memory.heapValueHandle(errorPtr)) } - const len = this.uint32Out.value.typedArray[0] - const ptr = outPtr.value.typedArray[0] - const pointerArray = new Uint32Array(this.module.HEAP8.buffer, ptr, len) + const len = this.uint32Out.value.typedArray.value[0] + const ptr = outPtr.value.typedArray.value[0] + const pointerArray = new Uint32Array(this.module.HEAPU8.buffer, ptr, len) const handles = Array.from(pointerArray).map((ptr) => this.memory.heapValueHandle(ptr as JSValuePointer), ) diff --git a/packages/quickjs-emscripten-core/src/memory.ts b/packages/quickjs-emscripten-core/src/memory.ts index 5c6417651..dd5e113b7 100644 --- a/packages/quickjs-emscripten-core/src/memory.ts +++ b/packages/quickjs-emscripten-core/src/memory.ts @@ -29,9 +29,39 @@ export interface TypedArrayConstructor { BYTES_PER_ELEMENT: number } +/** + * A TypedArray view into WASM memory that automatically refreshes when memory grows. + * + * When Emscripten's WASM memory grows (due to -sALLOW_MEMORY_GROWTH), all existing + * TypedArray views become detached because the underlying ArrayBuffer is replaced. + * This class lazily recreates the view when the buffer changes. + * + * @private + */ +export class RefreshableTypedArray { + private cachedArray: T | undefined + private lastBuffer: ArrayBufferLike | undefined + + constructor( + private readonly module: EitherModule, + private readonly kind: TypedArrayConstructor, + private readonly ptr: number, + private readonly length: number, + ) {} + + get value(): T { + const currentBuffer = this.module.HEAPU8.buffer + if (this.cachedArray === undefined || this.lastBuffer !== currentBuffer) { + this.cachedArray = new this.kind(currentBuffer, this.ptr, this.length) + this.lastBuffer = currentBuffer + } + return this.cachedArray + } +} + /** @private */ export type HeapTypedArray = Lifetime<{ - typedArray: JS + typedArray: RefreshableTypedArray ptr: C }> @@ -54,18 +84,17 @@ export class ModuleMemory { kind: TypedArrayConstructor, length: number, ): HeapTypedArray { - const zeros = new kind(new Array(length).fill(0)) - const numBytes = zeros.length * zeros.BYTES_PER_ELEMENT + const numBytes = length * kind.BYTES_PER_ELEMENT const ptr = this.module._malloc(numBytes) as C - const typedArray = new kind(this.module.HEAPU8.buffer, ptr, length) - typedArray.set(zeros) + const typedArray = new RefreshableTypedArray(this.module, kind, ptr, length) + typedArray.value.fill(0) return new Lifetime({ typedArray, ptr }, undefined, (value) => this.module._free(value.ptr)) } // TODO: shouldn't this be Uint32 instead of Int32? newMutablePointerArray( length: number, - ): Lifetime<{ typedArray: Int32Array; ptr: T }> { + ): Lifetime<{ typedArray: RefreshableTypedArray>; ptr: T }> { return this.newTypedArray(Int32Array, length) } diff --git a/packages/quickjs-emscripten-core/src/runtime.ts b/packages/quickjs-emscripten-core/src/runtime.ts index e404f0648..061838129 100644 --- a/packages/quickjs-emscripten-core/src/runtime.ts +++ b/packages/quickjs-emscripten-core/src/runtime.ts @@ -251,7 +251,7 @@ export class QuickJSRuntime extends UsingDisposable implements Disposable { ctxPtrOut.value.ptr, ) - const ctxPtr = ctxPtrOut.value.typedArray[0] as JSContextPointer + const ctxPtr = ctxPtrOut.value.typedArray.value[0] as JSContextPointer ctxPtrOut.dispose() if (ctxPtr === 0) { // No jobs executed. From 1df90f10833c6ffddefec9a57b9f26801ee46ea6 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Mon, 16 Feb 2026 15:19:43 -0500 Subject: [PATCH 2/8] Fix formatting in context.ts Co-Authored-By: Claude Opus 4.5 --- packages/quickjs-emscripten-core/src/context.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/quickjs-emscripten-core/src/context.ts b/packages/quickjs-emscripten-core/src/context.ts index a4e0323d8..969aaea80 100644 --- a/packages/quickjs-emscripten-core/src/context.ts +++ b/packages/quickjs-emscripten-core/src/context.ts @@ -487,9 +487,9 @@ export class QuickJSContext mutablePointerArray.value.ptr, ) const promiseHandle = this.memory.heapValueHandle(promisePtr) - const [resolveHandle, rejectHandle] = Array.from(mutablePointerArray.value.typedArray.value).map( - (jsvaluePtr) => this.memory.heapValueHandle(jsvaluePtr as any), - ) + const [resolveHandle, rejectHandle] = Array.from( + mutablePointerArray.value.typedArray.value, + ).map((jsvaluePtr) => this.memory.heapValueHandle(jsvaluePtr as any)) return new QuickJSDeferredPromise({ context: this, promiseHandle, From 4f1d8671de9b3725c52e7463c453e30b1ea21dc8 Mon Sep 17 00:00:00 2001 From: jackkav Date: Thu, 15 Oct 2026 20:48:49 +0200 Subject: [PATCH 3/8] Add regression tests for WASM memory growth view invalidation Co-Authored-By: Claude Sonnet 5.5 --- .../quickjs-emscripten/src/quickjs.test.ts | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/packages/quickjs-emscripten/src/quickjs.test.ts b/packages/quickjs-emscripten/src/quickjs.test.ts index f603c256c..2bdef9266 100644 --- a/packages/quickjs-emscripten/src/quickjs.test.ts +++ b/packages/quickjs-emscripten/src/quickjs.test.ts @@ -1191,6 +1191,98 @@ export default "the default"; }) }) + describe("WASM memory growth", () => { + // Growing the WASM heap replaces `module.HEAPU8.buffer` and detaches every + // TypedArray view created earlier. Reading a detached view yields `undefined`. + // These tests assert our out-parameter handling reads through the *current* heap. + const getModule = () => + (vm as any).module as { + HEAPU8: Uint8Array + _malloc(n: number): number + _free(p: number): void + } + + /** Force the heap to grow now. Throws if it didn't, so the tests below can't pass vacuously. */ + function growHeap() { + const module = getModule() + const before = module.HEAPU8.buffer + // Blocks that fit in existing free space don't grow the heap, so double the size until one doesn't. + // (Compare with `!==`: assert.notStrictEqual would try to print the whole heap on failure.) + for (let mb = 16; module.HEAPU8.buffer === before; mb *= 2) { + assert.ok(mb <= 512, "heap did not grow") + module._free(module._malloc(mb * 1024 * 1024)) + } + } + + it("newTypedArray reads current heap contents after growth", () => { + const memory = (vm as any).memory as any + using array = memory.newMutablePointerArray(2) + const staleView = array.value.typedArray.value + growHeap() + assert.equal(staleView.length, 0, "precondition: the old view is detached") + + const module = getModule() + new Int32Array(module.HEAPU8.buffer, array.value.ptr, 2).set([1234, 5678]) + assert.deepEqual(Array.from(array.value.typedArray.value), [1234, 5678]) + }) + + it("newTypedArray zero-initializes", () => { + const memory = (vm as any).memory as any + // Dirty the heap first so zeroing is observable. + const module = getModule() + const dirty = module._malloc(64) + module.HEAPU8.fill(0xff, dirty, dirty + 64) + module._free(dirty) + using array = memory.newMutablePointerArray(8) + assert.deepEqual(Array.from(array.value.typedArray.value), new Array(8).fill(0)) + }) + + it(".getLength() after the heap grew since the context was created", () => { + growHeap() + using array = vm.unwrapResult(vm.evalCode(`new Array(1234).fill(0)`)) + assert.strictEqual(vm.getLength(array), 1234) + }) + + it(".getOwnPropertyNames() after the heap grew, with many keys", () => { + growHeap() + const count = 50_000 + using object = vm.unwrapResult( + vm.evalCode(`const o = {}; for (let i = 0; i < ${count}; i++) o["k" + i] = i; o`), + ) + using names = vm.unwrapResult(vm.getOwnPropertyNames(object)) + assert.strictEqual(names.length, count) + assert.strictEqual(vm.getString(names[0]), "k0") + assert.strictEqual(vm.getString(names[count - 1]), `k${count - 1}`) + }) + + it(".executePendingJobs() when a job grows the heap", () => { + const module = getModule() + const bufferBefore = module.HEAPU8.buffer + // The heap may already be large from earlier tests, so the job allocates until the host + // sees the heap grow, rather than assuming a fixed amount is enough. + using heapGrew = vm.newFunction("heapGrew", () => + vm.newNumber(module.HEAPU8.buffer !== bufferBefore ? 1 : 0), + ) + vm.setProp(vm.global, "heapGrew", heapGrew) + vm.unwrapResult( + vm.evalCode(`Promise.resolve().then(() => { + const kept = [] + for (let n = 0; n < 1000; n++) { + kept.push(new ArrayBuffer(4 * 1024 * 1024)) + if (heapGrew()) return + } + throw new Error("heap never grew") + })`), + ).dispose() + const result = vm.runtime.executePendingJobs() + assert.ok(module.HEAPU8.buffer !== bufferBefore, "precondition: the job grew the heap") + // Throws if the job threw (e.g. "heap never grew"). + assert.strictEqual(vm.unwrapResult(result), 1) + // The job ran in `vm`; a stale ctxPtr would have created a second, leaked context. + assert.strictEqual((vm.runtime as any).contextMap.size, 1) + }) + }) + describe("memory pressure", () => { it("can pass a large string to a C function", async () => { const jsonString = await fs.promises.readFile( From 5dcfa68f9ab70b94c7c33700e362d4a89598d937 Mon Sep 17 00:00:00 2001 From: jackkav Date: Thu, 15 Oct 2026 23:04:57 +0200 Subject: [PATCH 4/8] Fix stale getArrayBuffer view and host memory leaks on failure paths - getArrayBuffer returned a HEAPU8.subarray view that is detached by the next WASM memory growth; return a copy instead. - consumeJSCharPointer / consumeHeapCharPointer leaked the C string when UTF8ToString threw (e.g. strings longer than V8's max string length). - newArrayBuffer leaked the host buffer (and returned an exception-tagged handle) when QuickJS failed to create the ArrayBuffer. It now frees the buffer and throws. Co-Authored-By: Claude Sonnet 5.5 --- .../quickjs-emscripten-core/src/context.ts | 28 ++++++-- .../quickjs-emscripten-core/src/memory.ts | 8 ++- .../quickjs-emscripten/src/quickjs.test.ts | 70 +++++++++++++++++++ 3 files changed, 96 insertions(+), 10 deletions(-) diff --git a/packages/quickjs-emscripten-core/src/context.ts b/packages/quickjs-emscripten-core/src/context.ts index 969aaea80..b5010ddd2 100644 --- a/packages/quickjs-emscripten-core/src/context.ts +++ b/packages/quickjs-emscripten-core/src/context.ts @@ -127,9 +127,11 @@ class ContextMemory extends ModuleMemory implements Disposable { } consumeJSCharPointer(ptr: JSBorrowedCharPointer): string { - const str = this.module.UTF8ToString(ptr) - this.ffi.QTS_FreeCString(this.ctx.value, ptr) - return str + try { + return this.module.UTF8ToString(ptr) + } finally { + this.ffi.QTS_FreeCString(this.ctx.value, ptr) + } } heapValueHandle(ptr: JSValuePointer, extraDispose?: () => void): JSValue { @@ -448,6 +450,13 @@ export class QuickJSContext const array = new Uint8Array(buffer) const handle = this.memory.newHeapBufferPointer(array) const ptr = this.ffi.QTS_NewArrayBuffer(this.ctx.value, handle.value.pointer, array.length) + const errorPtr = this.ffi.QTS_ResolveException(this.ctx.value, ptr) + if (errorPtr) { + // QuickJS only takes ownership of the host buffer when it succeeds in creating the ArrayBuffer. + handle.dispose() + this.ffi.QTS_FreeValuePointer(this.ctx.value, ptr) + return this.unwrapResult(this.fail(this.memory.heapValueHandle(errorPtr))) + } return this.memory.heapValueHandle(ptr) } @@ -806,7 +815,9 @@ export class QuickJSContext } /** - * Coverts `handle` to a JavaScript ArrayBuffer + * Coverts `handle` to a JavaScript ArrayBuffer. + * + * The returned bytes are a copy that stays valid however much the WASM heap grows afterwards. */ getArrayBuffer(handle: QuickJSHandle): Lifetime { this.runtime.assertOwned(handle) @@ -815,9 +826,12 @@ export class QuickJSContext if (!ptr) { throw new Error("Couldn't allocate memory to get ArrayBuffer") } - return new Lifetime(this.module.HEAPU8.subarray(ptr, ptr + len), undefined, () => - this.module._free(ptr), - ) + try { + // A view into the heap would be detached by the next memory growth, so copy out. + return new Lifetime(this.module.HEAPU8.slice(ptr, ptr + len)) + } finally { + this.module._free(ptr) + } } /** diff --git a/packages/quickjs-emscripten-core/src/memory.ts b/packages/quickjs-emscripten-core/src/memory.ts index dd5e113b7..d5bb54054 100644 --- a/packages/quickjs-emscripten-core/src/memory.ts +++ b/packages/quickjs-emscripten-core/src/memory.ts @@ -116,8 +116,10 @@ export class ModuleMemory { } consumeHeapCharPointer(ptr: OwnedHeapCharPointer): string { - const str = this.module.UTF8ToString(ptr) - this.module._free(ptr) - return str + try { + return this.module.UTF8ToString(ptr) + } finally { + this.module._free(ptr) + } } } diff --git a/packages/quickjs-emscripten/src/quickjs.test.ts b/packages/quickjs-emscripten/src/quickjs.test.ts index 2bdef9266..d2cdfeab4 100644 --- a/packages/quickjs-emscripten/src/quickjs.test.ts +++ b/packages/quickjs-emscripten/src/quickjs.test.ts @@ -1255,6 +1255,13 @@ export default "the default"; assert.strictEqual(vm.getString(names[count - 1]), `k${count - 1}`) }) + it(".getArrayBuffer() result is still readable after the heap grew", () => { + using handle = vm.unwrapResult(vm.evalCode(`new Uint8Array([1, 2, 3, 4]).buffer`)) + using bytes = vm.getArrayBuffer(handle) + growHeap() + assert.deepEqual(Array.from(bytes.value), [1, 2, 3, 4]) + }) + it(".executePendingJobs() when a job grows the heap", () => { const module = getModule() const bufferBefore = module.HEAPU8.buffer @@ -1283,6 +1290,69 @@ export default "the default"; }) }) + describe("failure paths free host memory", () => { + /** Record every pointer malloc'd and freed while `fn` runs. */ + function trackHeap(fn: () => T): { + result: T | undefined + error: unknown + leaked: number[] + } { + const module = (vm as any).module + const { _malloc, _free } = module + const live = new Set() + module._malloc = (n: number) => { + const ptr = _malloc(n) + live.add(ptr) + return ptr + } + module._free = (ptr: number) => { + live.delete(ptr) + return _free(ptr) + } + try { + return { result: fn(), error: undefined, leaked: [...live] } + } catch (error) { + return { result: undefined, error, leaked: [...live] } + } finally { + module._malloc = _malloc + module._free = _free + live.forEach((ptr) => _free(ptr)) + } + } + + it(".getString() frees the C string if decoding throws", () => { + using handle = vm.newString("hello") + const module = (vm as any).module + const UTF8ToString = module.UTF8ToString + const ffi = (vm as any).ffi + const QTS_FreeCString = ffi.QTS_FreeCString + let freed = 0 + ffi.QTS_FreeCString = (...args: unknown[]) => { + freed++ + return QTS_FreeCString.apply(ffi, args) + } + module.UTF8ToString = () => { + throw new Error("decode failed") + } + try { + assert.throws(() => vm.getString(handle), /decode failed/) + } finally { + module.UTF8ToString = UTF8ToString + ffi.QTS_FreeCString = QTS_FreeCString + } + assert.strictEqual(freed, 1) + }) + + it(".newArrayBuffer() frees the host buffer and throws if QuickJS cannot create it", () => { + // With no headroom, allocating the ArrayBuffer object fails. + vm.runtime.setMemoryLimit(1) + const { error, leaked } = trackHeap(() => vm.newArrayBuffer(new Uint8Array([1, 2, 3]).buffer)) + vm.runtime.setMemoryLimit(-1) + assert.ok(error, "expected newArrayBuffer to throw") + assert.deepEqual(leaked, []) + }) + }) + describe("memory pressure", () => { it("can pass a large string to a C function", async () => { const jsonString = await fs.promises.readFile( From b95d84435c17b2285e52ec78f5efaca46c03f9bc Mon Sep 17 00:00:00 2001 From: jackkav Date: Thu, 15 Oct 2026 23:07:32 +0200 Subject: [PATCH 5/8] Check for null mallocs, fix getOwnPropertyNames free and HostRefMap freelist - ModuleMemory helpers now throw on a null _malloc instead of writing at address 0. - getOwnPropertyNames freed a plain-malloc'd array with js_free (QTS_FreeVoidPointer), unbalancing QuickJS allocation counters; free it with the module's free. - HostRefMap: FIFO freelist used Array#shift (quadratic when draining); use a head index. Deleting an already-deleted id in a non-empty group no longer queues it for reuse twice. Co-Authored-By: Claude Sonnet 5.5 --- .../quickjs-emscripten-core/src/context.ts | 3 +- .../src/host-ref.test.ts | 21 ++++++++++++++ .../quickjs-emscripten-core/src/host-ref.ts | 17 +++++++++-- .../quickjs-emscripten-core/src/memory.ts | 17 ++++++++--- .../quickjs-emscripten/src/quickjs.test.ts | 28 +++++++++++++++++++ 5 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/quickjs-emscripten-core/src/context.ts b/packages/quickjs-emscripten-core/src/context.ts index b5010ddd2..142d30e30 100644 --- a/packages/quickjs-emscripten-core/src/context.ts +++ b/packages/quickjs-emscripten-core/src/context.ts @@ -1072,7 +1072,8 @@ export class QuickJSContext const handles = Array.from(pointerArray).map((ptr) => this.memory.heapValueHandle(ptr as JSValuePointer), ) - this.ffi.QTS_FreeVoidPointer(this.ctx.value, ptr as JSVoidPointer) + // The C side allocated this array with plain malloc, not js_malloc. + this.module._free(ptr) return this.success(createDisposableArray(handles)) }) } diff --git a/packages/quickjs-emscripten-core/src/host-ref.test.ts b/packages/quickjs-emscripten-core/src/host-ref.test.ts index cc0028733..4748a2c00 100644 --- a/packages/quickjs-emscripten-core/src/host-ref.test.ts +++ b/packages/quickjs-emscripten-core/src/host-ref.test.ts @@ -110,6 +110,27 @@ describe("HostRefMap", () => { }) describe("ID recycling (freelist)", () => { + it("does not hand out an id twice if it is deleted twice while its group is non-empty", () => { + const map = new HostRefMap() + const id1 = map.put({ a: 1 }) + map.put({ b: 2 }) // keeps id1's group alive + map.delete(id1) + assert.throws(() => map.delete(id1), QuickJSHostRefInvalid) + const reused = map.put({ c: 3 }) + const fresh = map.put({ d: 4 }) + assert.strictEqual(reused, id1) + assert.notStrictEqual(fresh, id1) + }) + + it("keeps FIFO order across compaction of the freelist", () => { + const map = new HostRefMap() + const ids = Array.from({ length: 5000 }, (_, i) => map.put({ i })) + ids.forEach((id) => map.delete(id)) + const reused = ids.map((_, i) => map.put({ i })) + assert.deepStrictEqual(reused, ids) + assert.ok(!ids.includes(map.put({ fresh: true })), "falls back to new ids once drained") + }) + it("reuses deleted IDs", () => { const map = new HostRefMap() const id1 = map.put({ first: true }) diff --git a/packages/quickjs-emscripten-core/src/host-ref.ts b/packages/quickjs-emscripten-core/src/host-ref.ts index 664f8c197..37636d5f9 100644 --- a/packages/quickjs-emscripten-core/src/host-ref.ts +++ b/packages/quickjs-emscripten-core/src/host-ref.ts @@ -17,7 +17,9 @@ function getGroupId(id: HostRefId): number { */ export class HostRefMap { private nextId = INT32_MIN + /** FIFO queue of ids to reuse: `freelist[freelistHead..]`. */ private freelist: HostRefId[] = [] + private freelistHead = 0 private groups = new Map>() put(value: object): HostRefId { @@ -62,7 +64,10 @@ export class HostRefMap { throw new QuickJSHostRefInvalid(`host reference id ${id} is not defined`) } - group.delete(id) + if (!group.delete(id)) { + // Recycling an already-deleted id again would hand it out twice. + throw new QuickJSHostRefInvalid(`host reference id ${id} is not defined`) + } if (group.size === 0) { this.groups.delete(groupId) } @@ -71,8 +76,14 @@ export class HostRefMap { } private allocateId(): HostRefId { - if (this.freelist.length > 0) { - return this.freelist.shift()! + if (this.freelistHead < this.freelist.length) { + const id = this.freelist[this.freelistHead++] + // Drop consumed entries occasionally; Array#shift per allocation is O(n). + if (this.freelistHead >= 1024 && this.freelistHead * 2 >= this.freelist.length) { + this.freelist = this.freelist.slice(this.freelistHead) + this.freelistHead = 0 + } + return id } if (this.nextId === INVALID_HOST_REF_ID) { diff --git a/packages/quickjs-emscripten-core/src/memory.ts b/packages/quickjs-emscripten-core/src/memory.ts index d5bb54054..0b9e60ed4 100644 --- a/packages/quickjs-emscripten-core/src/memory.ts +++ b/packages/quickjs-emscripten-core/src/memory.ts @@ -71,10 +71,19 @@ export type HeapTypedArray = Lifetime<{ export class ModuleMemory { constructor(public module: EitherModule) {} + /** `_malloc` that throws instead of returning a null pointer we would then write through. */ + private malloc(numBytes: number): T { + const ptr = this.module._malloc(numBytes) + if (!ptr && numBytes > 0) { + throw new Error(`Failed to allocate ${numBytes} bytes in the WASM heap (out of memory)`) + } + return ptr as T + } + toPointerArray(handleArray: QuickJSHandle[]): Lifetime { const typedArray = new Int32Array(handleArray.map((handle) => handle.value)) const numBytes = typedArray.length * typedArray.BYTES_PER_ELEMENT - const ptr = this.module._malloc(numBytes) as JSValueConstPointerPointer + const ptr = this.malloc(numBytes) const heapBytes = new Uint8Array(this.module.HEAPU8.buffer, ptr, numBytes) heapBytes.set(new Uint8Array(typedArray.buffer)) return new Lifetime(ptr, undefined, (ptr) => this.module._free(ptr)) @@ -85,7 +94,7 @@ export class ModuleMemory { length: number, ): HeapTypedArray { const numBytes = length * kind.BYTES_PER_ELEMENT - const ptr = this.module._malloc(numBytes) as C + const ptr = this.malloc(numBytes) const typedArray = new RefreshableTypedArray(this.module, kind, ptr, length) typedArray.value.fill(0) return new Lifetime({ typedArray, ptr }, undefined, (value) => this.module._free(value.ptr)) @@ -101,14 +110,14 @@ export class ModuleMemory { newHeapCharPointer(string: string): Lifetime<{ ptr: OwnedHeapCharPointer; strlen: number }> { const strlen = this.module.lengthBytesUTF8(string) const dataBytes = strlen + 1 - const ptr: OwnedHeapCharPointer = this.module._malloc(dataBytes) as OwnedHeapCharPointer + const ptr = this.malloc(dataBytes) this.module.stringToUTF8(string, ptr, dataBytes) return new Lifetime({ ptr, strlen }, undefined, (value) => this.module._free(value.ptr)) } newHeapBufferPointer(buffer: Uint8Array): Lifetime { const numBytes = buffer.byteLength - const ptr: JSVoidPointer = this.module._malloc(numBytes) as JSVoidPointer + const ptr = this.malloc(numBytes) this.module.HEAPU8.set(buffer, ptr) return new Lifetime({ pointer: ptr, numBytes }, undefined, (value) => this.module._free(value.pointer), diff --git a/packages/quickjs-emscripten/src/quickjs.test.ts b/packages/quickjs-emscripten/src/quickjs.test.ts index d2cdfeab4..aeb723148 100644 --- a/packages/quickjs-emscripten/src/quickjs.test.ts +++ b/packages/quickjs-emscripten/src/quickjs.test.ts @@ -1343,6 +1343,34 @@ export default "the default"; assert.strictEqual(freed, 1) }) + it("throws instead of writing through a null pointer when the WASM heap is exhausted", () => { + const module = (vm as any).module + const _malloc = module._malloc + module._malloc = () => 0 + try { + assert.throws(() => vm.newString("hello"), /out of memory/) + assert.throws(() => vm.newArrayBuffer(new Uint8Array([1, 2, 3]).buffer), /out of memory/) + assert.throws(() => vm.callFunction(vm.global, vm.undefined, vm.undefined), /out of memory/) + } finally { + module._malloc = _malloc + } + }) + + it(".getOwnPropertyNames() does not unbalance QuickJS allocation counters", () => { + using object = vm.unwrapResult(vm.evalCode(`({ a: 1, b: 2, c: 3 })`)) + const count = () => { + const usage = vm.runtime.computeMemoryUsage() + const { malloc_count } = vm.dump(usage) + usage.dispose() + return malloc_count as number + } + const before = count() + using names = vm.unwrapResult(vm.getOwnPropertyNames(object)) + names.dispose() + // The out-array is plain malloc'd memory; freeing it with js_free would drop the count. + assert.strictEqual(count(), before) + }) + it(".newArrayBuffer() frees the host buffer and throws if QuickJS cannot create it", () => { // With no headroom, allocating the ArrayBuffer object fails. vm.runtime.setMemoryLimit(1) From 75ffeccafe15cc5bba10beae625b2e01a44d4609 Mon Sep 17 00:00:00 2001 From: jackkav Date: Thu, 15 Oct 2026 23:07:42 +0200 Subject: [PATCH 6/8] Remove unused import Co-Authored-By: Claude Sonnet 5.5 --- packages/quickjs-emscripten-core/src/context.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/quickjs-emscripten-core/src/context.ts b/packages/quickjs-emscripten-core/src/context.ts index 142d30e30..48afd2de6 100644 --- a/packages/quickjs-emscripten-core/src/context.ts +++ b/packages/quickjs-emscripten-core/src/context.ts @@ -12,7 +12,6 @@ import type { EitherFFI, UInt32Pointer, JSValuePointerPointerPointer, - JSVoidPointer, HostRefId, } from "@jitl/quickjs-ffi-types" import type { JSPromiseState } from "./deferred-promise" From 4ac28966ca6a22fcd3b5138e9a34b764a2055a80 Mon Sep 17 00:00:00 2001 From: jackkav Date: Fri, 16 Oct 2026 09:28:19 +0200 Subject: [PATCH 7/8] Fix truncated dumpMemoryUsage and stack overflow in QTS_Call with many arguments - QTS_RuntimeDumpMemoryUsage wrote into a fixed 1 KiB fmemopen buffer, truncating the report mid-line once more than a few object kinds exist. Use open_memstream. - QTS_Call built a variable-length array of argc values on the wasm shadow stack; a few hundred thousand arguments trapped the whole module. Heap allocate instead, and throw out-of-memory if that fails. Co-Authored-By: Claude Sonnet 5.5 --- c/interface.c | 26 +++++++++++---- .../quickjs-emscripten/src/quickjs.test.ts | 32 +++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/c/interface.c b/c/interface.c index 4546fc58f..16848f0ae 100644 --- a/c/interface.c +++ b/c/interface.c @@ -416,12 +416,17 @@ JSValue *QTS_RuntimeComputeMemoryUsage(JSRuntime *rt, JSContext *ctx) { } OwnedHeapChar *QTS_RuntimeDumpMemoryUsage(JSRuntime *rt) { - char *result = malloc(sizeof(char) * 1024); - FILE *memfile = fmemopen(result, 1024, "w"); + // The report grows with the number of object classes in use; a fixed-size buffer truncated it. + char *result = NULL; + size_t size = 0; + FILE *memfile = open_memstream(&result, &size); + if (!memfile) { + return NULL; + } JSMemoryUsage s; JS_ComputeMemoryUsage(rt, &s); JS_DumpMemoryUsage(memfile, &s, rt); - fclose(memfile); + fclose(memfile); // flushes and NUL-terminates `result`, which the caller frees with free() return result; } @@ -962,14 +967,23 @@ MaybeAsync(JSValue *) QTS_GetOwnPropertyNames(JSContext *ctx, JSValue ***out_ptr } MaybeAsync(JSValue *) QTS_Call(JSContext *ctx, JSValueConst *func_obj, JSValueConst *this_obj, int argc, JSValueConst **argv_ptrs) { - // convert array of pointers to array of values - JSValueConst argv[argc]; + // Convert array of pointers to array of values. This is heap allocated: a variable-length + // array of a very large argc overflows the wasm shadow stack and traps the whole module. + JSValueConst *argv = NULL; + if (argc > 0) { + argv = malloc(sizeof(JSValueConst) * (size_t)argc); + if (!argv) { + return jsvalue_to_heap(JS_ThrowOutOfMemory(ctx)); + } + } int i; for (i = 0; i < argc; i++) { argv[i] = *(argv_ptrs[i]); } - return jsvalue_to_heap(JS_Call(ctx, *func_obj, *this_obj, argc, argv)); + JSValue result = JS_Call(ctx, *func_obj, *this_obj, argc, argv); + free(argv); + return jsvalue_to_heap(result); } /** diff --git a/packages/quickjs-emscripten/src/quickjs.test.ts b/packages/quickjs-emscripten/src/quickjs.test.ts index aeb723148..b0d3c18dd 100644 --- a/packages/quickjs-emscripten/src/quickjs.test.ts +++ b/packages/quickjs-emscripten/src/quickjs.test.ts @@ -1381,6 +1381,38 @@ export default "the default"; }) }) + describe("large inputs to C", () => { + it(".dumpMemoryUsage() is not truncated for a runtime with many object kinds", () => { + vm.unwrapResult( + vm.evalCode(` + globalThis.f = function () { return 1 } + globalThis.o = { a: 1, b: [1, 2, 3], c: new Map([[1, 2]]) } + globalThis.p = Promise.resolve() + globalThis.A = class A { m() {} } + globalThis.e = new Error("e") + globalThis.u8 = new Uint8Array(10) + globalThis.rx = /a+/g + `), + ).dispose() + const dump = vm.runtime.dumpMemoryUsage() + // The old fixed 1 KiB buffer cut the report off mid-line. + assert.ok(dump.endsWith("\n"), `report ends mid-line: ${JSON.stringify(dump.slice(-40))}`) + }) + + it(".callFunction() with a very large argument list returns a result or a catchable error", () => { + using fn = vm.unwrapResult(vm.evalCode(`(...args) => args.length`)) + const args = new Array(1_000_000).fill(vm.undefined) + const result = vm.callFunction(fn, vm.undefined, args) + if (result.error) { + result.error.dispose() + } else { + result.value.dispose() + } + // A variable-length array of this size on the wasm stack used to trap the whole module. + assert.strictEqual(vm.getNumber(vm.unwrapResult(vm.evalCode(`1 + 1`))), 2) + }) + }) + describe("memory pressure", () => { it("can pass a large string to a C function", async () => { const jsonString = await fs.promises.readFile( From 31aac347a97c3f59c0a1afc1dfbed9f3ab38d4cd Mon Sep 17 00:00:00 2001 From: jackkav Date: Fri, 16 Oct 2026 11:06:21 +0200 Subject: [PATCH 8/8] Handle allocation failure in newContext, heapValueHandle and QTS_GetOwnPropertyNames - newContext (sync and asyncify) wrapped a NULL context pointer and carried on; throw. - ContextMemory.heapValueHandle wrapped the NULL returned by jsvalue_to_heap when it cannot malloc; throw. - QTS_GetOwnPropertyNames did not check its output malloc, and sized it per JSValue (8 bytes) instead of per JSValue* (4 bytes); check it, free the property atoms and throw out-of-memory on failure. Co-Authored-By: Claude Sonnet 5.5 --- c/interface.c | 11 ++++++++- .../quickjs-emscripten-core/src/context.ts | 4 ++++ .../src/runtime-asyncify.ts | 18 +++++++-------- .../quickjs-emscripten-core/src/runtime.ts | 18 +++++++-------- .../quickjs-emscripten/src/quickjs.test.ts | 23 +++++++++++++++++++ 5 files changed, 55 insertions(+), 19 deletions(-) diff --git a/c/interface.c b/c/interface.c index 16848f0ae..de6357e0a 100644 --- a/c/interface.c +++ b/c/interface.c @@ -931,7 +931,16 @@ MaybeAsync(JSValue *) QTS_GetOwnPropertyNames(JSContext *ctx, JSValue ***out_ptr } return jsvalue_to_heap(JS_GetException(ctx)); } - *out_ptrs = malloc(sizeof(JSValue) * total_props); + // One pointer per property (the entries are JSValue*, not JSValue). Never malloc(0): NULL is our failure signal. + *out_ptrs = malloc(sizeof(JSValue *) * (total_props > 0 ? total_props : 1)); + if (*out_ptrs == NULL) { + for (uint32_t i = 0; i < total_props; i++) { + JS_FreeAtom(ctx, tab[i].atom); + } + js_free(ctx, tab); + JS_ThrowOutOfMemory(ctx); + return jsvalue_to_heap(JS_GetException(ctx)); + } for (int i = 0; i < total_props; i++) { JSAtom atom = tab[i].atom; diff --git a/packages/quickjs-emscripten-core/src/context.ts b/packages/quickjs-emscripten-core/src/context.ts index 48afd2de6..177e235e8 100644 --- a/packages/quickjs-emscripten-core/src/context.ts +++ b/packages/quickjs-emscripten-core/src/context.ts @@ -134,6 +134,10 @@ class ContextMemory extends ModuleMemory implements Disposable { } heapValueHandle(ptr: JSValuePointer, extraDispose?: () => void): JSValue { + if (!ptr) { + // The C side returns NULL when it cannot allocate the heap copy of a JSValue. + throw new Error("Failed to allocate a JSValue handle (out of memory)") + } const dispose: typeof this.freeJSValue = extraDispose ? (val) => { extraDispose() diff --git a/packages/quickjs-emscripten-core/src/runtime-asyncify.ts b/packages/quickjs-emscripten-core/src/runtime-asyncify.ts index 8d99bd75d..5a94cf809 100644 --- a/packages/quickjs-emscripten-core/src/runtime-asyncify.ts +++ b/packages/quickjs-emscripten-core/src/runtime-asyncify.ts @@ -48,15 +48,15 @@ export class QuickJSAsyncRuntime extends QuickJSRuntime { override newContext(options: ContextOptions = {}): QuickJSAsyncContext { const intrinsics = intrinsicsToFlags(options.intrinsics) - const ctx = new Lifetime( - this.ffi.QTS_NewContext(this.rt.value, intrinsics), - undefined, - (ctx_ptr) => { - this.contextMap.delete(ctx_ptr) - this.callbacks.deleteContext(ctx_ptr) - this.ffi.QTS_FreeContext(ctx_ptr) - }, - ) + const ctxPtr = this.ffi.QTS_NewContext(this.rt.value, intrinsics) + if (!ctxPtr) { + throw new Error("Failed to create QuickJSContext (out of memory)") + } + const ctx = new Lifetime(ctxPtr, undefined, (ctx_ptr) => { + this.contextMap.delete(ctx_ptr) + this.callbacks.deleteContext(ctx_ptr) + this.ffi.QTS_FreeContext(ctx_ptr) + }) const context = new QuickJSAsyncContext({ module: this.module, diff --git a/packages/quickjs-emscripten-core/src/runtime.ts b/packages/quickjs-emscripten-core/src/runtime.ts index 061838129..64ea855d4 100644 --- a/packages/quickjs-emscripten-core/src/runtime.ts +++ b/packages/quickjs-emscripten-core/src/runtime.ts @@ -142,15 +142,15 @@ export class QuickJSRuntime extends UsingDisposable implements Disposable { */ newContext(options: ContextOptions = {}): QuickJSContext { const intrinsics = intrinsicsToFlags(options.intrinsics) - const ctx = new Lifetime( - options.contextPointer || this.ffi.QTS_NewContext(this.rt.value, intrinsics), - undefined, - (ctx_ptr) => { - this.contextMap.delete(ctx_ptr) - this.callbacks.deleteContext(ctx_ptr) - this.ffi.QTS_FreeContext(ctx_ptr) - }, - ) + const ctxPtr = options.contextPointer || this.ffi.QTS_NewContext(this.rt.value, intrinsics) + if (!ctxPtr) { + throw new Error("Failed to create QuickJSContext (out of memory)") + } + const ctx = new Lifetime(ctxPtr, undefined, (ctx_ptr) => { + this.contextMap.delete(ctx_ptr) + this.callbacks.deleteContext(ctx_ptr) + this.ffi.QTS_FreeContext(ctx_ptr) + }) const context = new QuickJSContext({ module: this.module, diff --git a/packages/quickjs-emscripten/src/quickjs.test.ts b/packages/quickjs-emscripten/src/quickjs.test.ts index b0d3c18dd..a7f345e65 100644 --- a/packages/quickjs-emscripten/src/quickjs.test.ts +++ b/packages/quickjs-emscripten/src/quickjs.test.ts @@ -1413,6 +1413,29 @@ export default "the default"; }) }) + describe("allocation failure", () => { + it("newContext() throws if QuickJS cannot create the context", () => { + vm.runtime.setMemoryLimit(1) + try { + assert.throws(() => vm.runtime.newContext(), /out of memory/) + } finally { + vm.runtime.setMemoryLimit(-1) + } + }) + + it("heapValueHandle() throws on the NULL pointer the C side returns when it cannot allocate", () => { + const memory = (vm as any).memory + assert.throws(() => memory.heapValueHandle(0), /out of memory/) + }) + + it(".getOwnPropertyNames() works for objects with no own properties", () => { + // The C side must not treat an empty allocation as a failure. + using object = vm.newObject() + using names = vm.unwrapResult(vm.getOwnPropertyNames(object)) + assert.strictEqual(names.length, 0) + }) + }) + describe("memory pressure", () => { it("can pass a large string to a C function", async () => { const jsonString = await fs.promises.readFile(