Skip to content

Commit 57ce055

Browse files
justjakeclaude
andcommitted
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 <noreply@anthropic.com>
1 parent df4efb9 commit 57ce055

3 files changed

Lines changed: 47 additions & 12 deletions

File tree

packages/quickjs-emscripten-core/src/context.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -487,7 +487,7 @@ export class QuickJSContext
487487
mutablePointerArray.value.ptr,
488488
)
489489
const promiseHandle = this.memory.heapValueHandle(promisePtr)
490-
const [resolveHandle, rejectHandle] = Array.from(mutablePointerArray.value.typedArray).map(
490+
const [resolveHandle, rejectHandle] = Array.from(mutablePointerArray.value.typedArray.value).map(
491491
(jsvaluePtr) => this.memory.heapValueHandle(jsvaluePtr as any),
492492
)
493493
return new QuickJSDeferredPromise({
@@ -994,7 +994,7 @@ export class QuickJSContext
994994
if (status < 0) {
995995
return undefined
996996
}
997-
return this.uint32Out.value.typedArray[0]
997+
return this.uint32Out.value.typedArray.value[0]
998998
}
999999

10001000
/**
@@ -1052,9 +1052,9 @@ export class QuickJSContext
10521052
if (errorPtr) {
10531053
return this.fail(this.memory.heapValueHandle(errorPtr))
10541054
}
1055-
const len = this.uint32Out.value.typedArray[0]
1056-
const ptr = outPtr.value.typedArray[0]
1057-
const pointerArray = new Uint32Array(this.module.HEAP8.buffer, ptr, len)
1055+
const len = this.uint32Out.value.typedArray.value[0]
1056+
const ptr = outPtr.value.typedArray.value[0]
1057+
const pointerArray = new Uint32Array(this.module.HEAPU8.buffer, ptr, len)
10581058
const handles = Array.from(pointerArray).map((ptr) =>
10591059
this.memory.heapValueHandle(ptr as JSValuePointer),
10601060
)

packages/quickjs-emscripten-core/src/memory.ts

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,39 @@ export interface TypedArrayConstructor<T> {
2929
BYTES_PER_ELEMENT: number
3030
}
3131

32+
/**
33+
* A TypedArray view into WASM memory that automatically refreshes when memory grows.
34+
*
35+
* When Emscripten's WASM memory grows (due to -sALLOW_MEMORY_GROWTH), all existing
36+
* TypedArray views become detached because the underlying ArrayBuffer is replaced.
37+
* This class lazily recreates the view when the buffer changes.
38+
*
39+
* @private
40+
*/
41+
export class RefreshableTypedArray<T extends TypedArray> {
42+
private cachedArray: T | undefined
43+
private lastBuffer: ArrayBufferLike | undefined
44+
45+
constructor(
46+
private readonly module: EitherModule,
47+
private readonly kind: TypedArrayConstructor<T>,
48+
private readonly ptr: number,
49+
private readonly length: number,
50+
) {}
51+
52+
get value(): T {
53+
const currentBuffer = this.module.HEAPU8.buffer
54+
if (this.cachedArray === undefined || this.lastBuffer !== currentBuffer) {
55+
this.cachedArray = new this.kind(currentBuffer, this.ptr, this.length)
56+
this.lastBuffer = currentBuffer
57+
}
58+
return this.cachedArray
59+
}
60+
}
61+
3262
/** @private */
3363
export type HeapTypedArray<JS extends TypedArray, C extends number> = Lifetime<{
34-
typedArray: JS
64+
typedArray: RefreshableTypedArray<JS>
3565
ptr: C
3666
}>
3767

@@ -54,18 +84,23 @@ export class ModuleMemory {
5484
kind: TypedArrayConstructor<JS>,
5585
length: number,
5686
): HeapTypedArray<JS, C> {
57-
const zeros = new kind(new Array(length).fill(0))
58-
const numBytes = zeros.length * zeros.BYTES_PER_ELEMENT
87+
const numBytes = length * kind.BYTES_PER_ELEMENT
5988
const ptr = this.module._malloc(numBytes) as C
60-
const typedArray = new kind(this.module.HEAPU8.buffer, ptr, length)
61-
typedArray.set(zeros)
89+
90+
// Initialize memory to zeros
91+
const initArray = new kind(this.module.HEAPU8.buffer, ptr, length)
92+
initArray.fill(0)
93+
94+
// Create refreshable wrapper that handles memory growth
95+
const typedArray = new RefreshableTypedArray(this.module, kind, ptr, length)
96+
6297
return new Lifetime({ typedArray, ptr }, undefined, (value) => this.module._free(value.ptr))
6398
}
6499

65100
// TODO: shouldn't this be Uint32 instead of Int32?
66101
newMutablePointerArray<T extends number>(
67102
length: number,
68-
): Lifetime<{ typedArray: Int32Array; ptr: T }> {
103+
): Lifetime<{ typedArray: RefreshableTypedArray<Int32Array>; ptr: T }> {
69104
return this.newTypedArray(Int32Array, length)
70105
}
71106

packages/quickjs-emscripten-core/src/runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,7 @@ export class QuickJSRuntime extends UsingDisposable implements Disposable {
251251
ctxPtrOut.value.ptr,
252252
)
253253

254-
const ctxPtr = ctxPtrOut.value.typedArray[0] as JSContextPointer
254+
const ctxPtr = ctxPtrOut.value.typedArray.value[0] as JSContextPointer
255255
ctxPtrOut.dispose()
256256
if (ctxPtr === 0) {
257257
// No jobs executed.

0 commit comments

Comments
 (0)