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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 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 @@ -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);
}

/**
Expand Down
46 changes: 30 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,9 +126,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 {
Expand Down Expand Up @@ -448,6 +449,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 +495,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 +814,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 +825,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 +1007,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 +1065,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)
}
}
}
2 changes: 1 addition & 1 deletion packages/quickjs-emscripten-core/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading