diff --git a/c/interface.c b/c/interface.c index 4546fc58f..de6357e0a 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; } @@ -926,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; @@ -962,14 +976,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-core/src/context.ts b/packages/quickjs-emscripten-core/src/context.ts index 9bc7c8a0d..177e235e8 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" @@ -127,12 +126,18 @@ 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 { + 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() @@ -448,6 +453,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) } @@ -487,9 +499,9 @@ export class QuickJSContext mutablePointerArray.value.ptr, ) const promiseHandle = this.memory.heapValueHandle(promisePtr) - const [resolveHandle, rejectHandle] = Array.from(mutablePointerArray.value.typedArray).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, @@ -806,7 +818,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 +829,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) + } } /** @@ -994,7 +1011,7 @@ export class QuickJSContext if (status < 0) { return undefined } - return this.uint32Out.value.typedArray[0] + return this.uint32Out.value.typedArray.value[0] } /** @@ -1052,13 +1069,14 @@ 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), ) - 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 5c6417651..0b9e60ed4 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 }> @@ -41,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)) @@ -54,32 +93,31 @@ 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 ptr = this.module._malloc(numBytes) as C - const typedArray = new kind(this.module.HEAPU8.buffer, ptr, length) - typedArray.set(zeros) + const numBytes = length * kind.BYTES_PER_ELEMENT + 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)) } // 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) } 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), @@ -87,8 +125,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-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 e404f0648..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, @@ -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. diff --git a/packages/quickjs-emscripten/src/quickjs.test.ts b/packages/quickjs-emscripten/src/quickjs.test.ts index f603c256c..a7f345e65 100644 --- a/packages/quickjs-emscripten/src/quickjs.test.ts +++ b/packages/quickjs-emscripten/src/quickjs.test.ts @@ -1191,6 +1191,251 @@ 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(".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 + // 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("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("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) + 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("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("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(