From 2bc4b59be766c8b98441cd176f0597f530678f9f Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Mon, 16 Feb 2026 11:59:32 -0500 Subject: [PATCH 01/10] 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 02/10] 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 03/10] 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 04/10] 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 05/10] 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 06/10] 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 07/10] 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 08/10] 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( From 7898bceaf34174edb81ac22d38359c3de60d57f4 Mon Sep 17 00:00:00 2001 From: jackkav Date: Fri, 16 Oct 2026 11:17:42 +0200 Subject: [PATCH 09/10] Allow object references when decoding binary JSON QTS_bjson_encode writes with JS_WRITE_OBJ_REFERENCE so shared and cyclic objects encode, but QTS_bjson_decode read with flags 0, so decoding them failed with "object references are not allowed". Pass JS_READ_OBJ_REFERENCE. Bytecode and SharedArrayBuffer remain disallowed. Co-Authored-By: Claude Sonnet 5.5 --- c/interface.c | 5 +- .../quickjs-emscripten/src/quickjs.test.ts | 50 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/c/interface.c b/c/interface.c index de6357e0a..40f7a0f4b 100644 --- a/c/interface.c +++ b/c/interface.c @@ -1587,6 +1587,9 @@ JSValue *QTS_bjson_decode(JSContext *ctx, JSValueConst *data) { if (!buffer) return jsvalue_to_heap(JS_EXCEPTION); - JSValue value = JS_ReadObject(ctx, buffer, length, 0); + // Must accept references: QTS_bjson_encode writes them (JS_WRITE_OBJ_REFERENCE) for shared + // and cyclic objects, and without this flag decoding such data fails with + // "object references are not allowed". Bytecode and SharedArrayBuffer stay disallowed. + JSValue value = JS_ReadObject(ctx, buffer, length, JS_READ_OBJ_REFERENCE); return jsvalue_to_heap(value); } \ No newline at end of file diff --git a/packages/quickjs-emscripten/src/quickjs.test.ts b/packages/quickjs-emscripten/src/quickjs.test.ts index a7f345e65..2050e8301 100644 --- a/packages/quickjs-emscripten/src/quickjs.test.ts +++ b/packages/quickjs-emscripten/src/quickjs.test.ts @@ -1436,6 +1436,56 @@ export default "the default"; }) }) + describe("binary JSON", () => { + const roundTrip = (source: string) => { + using value = vm.unwrapResult(vm.evalCode(source)) + using encoded = vm.encodeBinaryJSON(value) + using decoded = vm.decodeBinaryJSON(encoded) + return vm.dump(decoded) + } + + it("round-trips plain values", () => { + assert.deepEqual(roundTrip(`({ a: 1, b: [1, 2, { c: "d" }], e: null })`), { + a: 1, + b: [1, 2, { c: "d" }], + e: null, + }) + }) + + it("round-trips an object referenced more than once", () => { + const result = roundTrip( + `const shared = { n: 1 }; ({ a: shared, b: shared, list: [shared, shared] })`, + ) + assert.deepEqual(result, { a: { n: 1 }, b: { n: 1 }, list: [{ n: 1 }, { n: 1 }] }) + }) + + /** Encode the guest value of `source`, decode it into a global `decoded`, and evaluate `check`. */ + const decodedSatisfies = (source: string, check: string) => { + using value = vm.unwrapResult(vm.evalCode(source)) + using encoded = vm.encodeBinaryJSON(value) + using decoded = vm.decodeBinaryJSON(encoded) + vm.setProp(vm.global, "decoded", decoded) + using result = vm.unwrapResult(vm.evalCode(check)) + return vm.dump(result) + } + + it("preserves object identity for shared references", () => { + const same = decodedSatisfies( + `const shared = { n: 1 }; ({ a: shared, b: shared })`, + `decoded.a === decoded.b`, + ) + assert.strictEqual(same, true) + }) + + it("round-trips a cyclic object", () => { + const ok = decodedSatisfies( + `const o = { name: "loop" }; o.self = o; o`, + `decoded.name === "loop" && decoded.self === decoded`, + ) + assert.strictEqual(ok, true) + }) + }) + describe("memory pressure", () => { it("can pass a large string to a C function", async () => { const jsonString = await fs.promises.readFile( From 1f62e5583c99ad47eff67d3cb0ba0b324aba7136 Mon Sep 17 00:00:00 2001 From: jackkav Date: Fri, 16 Oct 2026 15:25:53 +0200 Subject: [PATCH 10/10] Size the asyncify buffer from the max stack size so deep suspensions cannot abort Asyncify's buffer holds the saved locals of each suspended wasm frame, which needs up to ~1.9x the native stack depth that JS_SetMaxStackSize limits (measured across release/debug, quickjs/quickjs-ng and several recursion shapes). With the buffer set to exactly the limit, suspending near the deepest recursion QuickJS allows overflowed it and aborted the module ("unreachable"). Set it to 3x the limit (at least the build default), capped at 1 GiB. Co-Authored-By: Claude Sonnet 5.5 --- c/interface.c | 16 +++++-- .../src/runtime-asyncify.ts | 6 ++- .../quickjs-emscripten/src/quickjs.test.ts | 48 +++++++++++++++++++ 3 files changed, 66 insertions(+), 4 deletions(-) diff --git a/c/interface.c b/c/interface.c index 40f7a0f4b..95601bd5c 100644 --- a/c/interface.c +++ b/c/interface.c @@ -447,8 +447,18 @@ int QTS_BuildIsSanitizeLeak() { } #ifdef QTS_ASYNCIFY -EM_JS(void, set_asyncify_stack_size, (size_t size, size_t default_size), { - Asyncify.StackSize = size || default_size; +// The asyncify buffer holds the saved locals of every suspended wasm frame. That needs more bytes +// than the native stack depth QuickJS limits with JS_SetMaxStackSize: measured up to 1.9x for +// recursive JS across release/debug and quickjs/quickjs-ng builds. If the buffer is smaller than +// the QuickJS limit requires, a deep suspension aborts the module ("unreachable") instead of +// throwing a catchable "stack overflow". Leave margin over the measured worst case. +#define QTS_ASYNCIFY_STACK_FACTOR 3 + +EM_JS(void, set_asyncify_stack_size, (size_t size, size_t default_size, size_t factor), { + size = size >>> 0; + default_size = default_size >>> 0; + // malloc'd once per suspension; cap well below the 2 GiB wasm heap limit. + Asyncify.StackSize = size ? Math.min(Math.max(size * factor, default_size), 0x40000000) : default_size; }); #endif @@ -458,7 +468,7 @@ EM_JS(void, set_asyncify_stack_size, (size_t size, size_t default_size), { void QTS_RuntimeSetMaxStackSize(JSRuntime *rt, size_t stack_size) { #ifdef QTS_ASYNCIFY - set_asyncify_stack_size(stack_size, QTS_ASYNCIFY_DEFAULT_STACK_SIZE); + set_asyncify_stack_size(stack_size, QTS_ASYNCIFY_DEFAULT_STACK_SIZE, QTS_ASYNCIFY_STACK_FACTOR); #endif JS_SetMaxStackSize(rt, stack_size); } diff --git a/packages/quickjs-emscripten-core/src/runtime-asyncify.ts b/packages/quickjs-emscripten-core/src/runtime-asyncify.ts index 5a94cf809..6cea2077c 100644 --- a/packages/quickjs-emscripten-core/src/runtime-asyncify.ts +++ b/packages/quickjs-emscripten-core/src/runtime-asyncify.ts @@ -86,7 +86,11 @@ export class QuickJSAsyncRuntime extends QuickJSRuntime { * Set the max stack size for this runtime in bytes. * To remove the limit, set to `0`. * - * Setting this limit also adjusts the global `ASYNCIFY_STACK_SIZE` for the entire {@link QuickJSAsyncWASMModule}. + * Setting this limit also adjusts the global `ASYNCIFY_STACK_SIZE` for the entire {@link QuickJSAsyncWASMModule} + * to 3x the limit (never less than the build's default), so that QuickJS reports a catchable + * "stack overflow" before an async host function can suspend deeper than the asyncify buffer holds. + * Without calling this, the build default (81920 bytes) holds roughly 300 nested suspended + * frames; suspending deeper aborts the WASM module, which cannot be recovered. * See the [pull request](https://github.com/justjake/quickjs-emscripten/pull/114) for more details. */ public override setMaxStackSize(stackSize: number): void { diff --git a/packages/quickjs-emscripten/src/quickjs.test.ts b/packages/quickjs-emscripten/src/quickjs.test.ts index 2050e8301..1d5838ff8 100644 --- a/packages/quickjs-emscripten/src/quickjs.test.ts +++ b/packages/quickjs-emscripten/src/quickjs.test.ts @@ -1668,6 +1668,54 @@ function asyncContextTests( // |---------------------|--------------------| // | 4096 (default) | 12 | // | 81920 | 297 | + // The asyncify buffer must hold every suspended frame QuickJS lets us create. If it is smaller, + // suspending in the deepest recursion QuickJS allows aborts the whole module ("unreachable"). + // Patterns that go through C functions use more host (V8) stack per level when unwinding, so they + // are only run with the smaller limit to stay under node's own call stack size. + const recursionPatterns: Record string, number[]]> = { + plain: [ + (leaf) => `function f(d) { return d === 0 ? ${leaf} : f(d - 1) + 0 }`, + [81920, 262144], + ], + arrayMap: [ + (leaf) => `function f(d) { return d === 0 ? ${leaf} : [d - 1].map(f)[0] + 0 }`, + [81920], + ], + apply: [ + (leaf) => `function f(d) { return d === 0 ? ${leaf} : f.apply(null, [d - 1]) + 0 }`, + [81920], + ], + } + for (const [name, [define, maxStackSizes]] of Object.entries(recursionPatterns)) { + for (const maxStackSize of maxStackSizes) { + it(`can suspend at the deepest ${name} recursion allowed by max stack ${maxStackSize}`, async () => { + vm.runtime.setMaxStackSize(maxStackSize) + const completesSync = (depth: number) => { + const result = vm.evalCode(`${define("0")}; f(${depth})`) + if (result.error) { + result.error.dispose() + return false + } + result.value.dispose() + return true + } + let [low, high] = [10, Math.floor(maxStackSize / 50)] + assert.ok(!completesSync(high), "upper bound should exceed the QuickJS stack limit") + while (low < high) { + const mid = Math.ceil((low + high) / 2) + if (completesSync(mid)) low = mid + else high = mid - 1 + } + + vm.newAsyncifiedFunction("hostSleep", async () => vm.newNumber(1)).consume((fn) => + vm.setProp(vm.global, "hostSleep", fn), + ) + const result = await vm.evalCodeAsync(`${define("hostSleep()")}; f(${low})`) + assert.strictEqual(vm.getNumber(vm.unwrapResult(result)), 1) + }) + } + } + it("is enough to support at least 20 levels of function nesting", async () => { // The nesting levels of the test cannot be too high, otherwise the // node.js call stack will overflow when executing `yarn test`