Skip to content
42 changes: 34 additions & 8 deletions c/interface.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -1564,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);
}
50 changes: 34 additions & 16 deletions packages/quickjs-emscripten-core/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import type {
EitherFFI,
UInt32Pointer,
JSValuePointerPointerPointer,
JSVoidPointer,
HostRefId,
} from "@jitl/quickjs-ffi-types"
import type { JSPromiseState } from "./deferred-promise"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<Uint8Array> {
this.runtime.assertOwned(handle)
Expand All @@ -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<Uint8Array>(this.module.HEAPU8.slice(ptr, ptr + len))
} finally {
this.module._free(ptr)
}
}

/**
Expand Down Expand Up @@ -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]
}

/**
Expand Down Expand Up @@ -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))
})
}
Expand Down
21 changes: 21 additions & 0 deletions packages/quickjs-emscripten-core/src/host-ref.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 })
Expand Down
17 changes: 14 additions & 3 deletions packages/quickjs-emscripten-core/src/host-ref.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, Map<HostRefId, object>>()

put(value: object): HostRefId {
Expand Down Expand Up @@ -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)
}
Expand All @@ -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) {
Expand Down
66 changes: 53 additions & 13 deletions packages/quickjs-emscripten-core/src/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,39 @@ export interface TypedArrayConstructor<T> {
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<T extends TypedArray> {
private cachedArray: T | undefined
private lastBuffer: ArrayBufferLike | undefined

constructor(
private readonly module: EitherModule,
private readonly kind: TypedArrayConstructor<T>,
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<JS extends TypedArray, C extends number> = Lifetime<{
typedArray: JS
typedArray: RefreshableTypedArray<JS>
ptr: C
}>

Expand All @@ -41,10 +71,19 @@ export type HeapTypedArray<JS extends TypedArray, C extends number> = Lifetime<{
export class ModuleMemory {
constructor(public module: EitherModule) {}

/** `_malloc` that throws instead of returning a null pointer we would then write through. */
private malloc<T extends number>(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<JSValueConstPointerPointer> {
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<JSValueConstPointerPointer>(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))
Expand All @@ -54,41 +93,42 @@ export class ModuleMemory {
kind: TypedArrayConstructor<JS>,
length: number,
): HeapTypedArray<JS, C> {
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<C>(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<T extends number>(
length: number,
): Lifetime<{ typedArray: Int32Array<ArrayBuffer>; ptr: T }> {
): Lifetime<{ typedArray: RefreshableTypedArray<Int32Array<ArrayBuffer>>; 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<OwnedHeapCharPointer>(dataBytes)
this.module.stringToUTF8(string, ptr, dataBytes)
return new Lifetime({ ptr, strlen }, undefined, (value) => this.module._free(value.ptr))
}

newHeapBufferPointer(buffer: Uint8Array): Lifetime<HeapUint8Array> {
const numBytes = buffer.byteLength
const ptr: JSVoidPointer = this.module._malloc(numBytes) as JSVoidPointer
const ptr = this.malloc<JSVoidPointer>(numBytes)
this.module.HEAPU8.set(buffer, ptr)
return new Lifetime({ pointer: ptr, numBytes }, undefined, (value) =>
this.module._free(value.pointer),
)
}

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)
}
}
}
18 changes: 9 additions & 9 deletions packages/quickjs-emscripten-core/src/runtime-asyncify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading