This is an automated email from the ASF dual-hosted git repository.
tlopex pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tvm.git
The following commit(s) were added to refs/heads/main by this push:
new 5285aa67ed [Web] Keep large byte arguments and suspended calls off the
reusable call stack (#20500)
5285aa67ed is described below
commit 5285aa67ed43d84c07f09295cc3643a500d2c080
Author: Akaash Parthasarathy <[email protected]>
AuthorDate: Thu Oct 1 21:40:36 2026 -0700
[Web] Keep large byte arguments and suspended calls off the reusable call
stack (#20500)
Three lifetime problems in the packed-call path of the web runtime.
1. Five helpers took a pooled call stack and returned it only on
success, so a TVM error inside the call left the stack unrecycled and
the next call allocated another one. They now return it in a finally
block.
2. Large byte arguments grow the reusable call stack, and one large call
raises its capacity for the rest of the session. Byte payloads now stay
out of the reusable stack once it reaches a 64 KiB soft limit and go
into a per-call Wasm allocation that is released when the call finishes
or throws, including across an Asyncify suspension. Offsets of
Wasm-backed views are recorded before any allocation can grow linear
memory and detach them.
3. A packed function can be disposed through another wrapper while an
Asyncify call is suspended, so the rewound call runs on a freed handle.
The call frame now takes a reference when the call first unwinds and
drops it when the frame is released. Synchronous calls do not take the
reference.
In a Node microbenchmark a synchronous call with one int argument goes
from 123 ns to about 165 ns, and 1 MiB byte arguments are 15 percent
faster because they no longer grow the stack. Tests cover byte arguments
across the soft limit, Wasm views that detach during a call, alias
disposal during an Asyncify call, and frame cleanup that throws.
---
web/emcc/wasm_runtime.cc | 29 +++
web/src/asyncify.ts | 10 +-
web/src/memory.ts | 168 +++++++++++++--
web/src/runtime.ts | 437 +++++++++++++++++++++++++++------------
web/tests/node/test_ffi_bytes.js | 289 ++++++++++++++++++++++++++
web/tests/node/test_memory.js | 234 +++++++++++++++++++++
6 files changed, 1013 insertions(+), 154 deletions(-)
diff --git a/web/emcc/wasm_runtime.cc b/web/emcc/wasm_runtime.cc
index 537e80d748..3c11216bd1 100644
--- a/web/emcc/wasm_runtime.cc
+++ b/web/emcc/wasm_runtime.cc
@@ -112,6 +112,16 @@ void LogMessageImpl(const std::string& file, int lineno,
int level, const std::s
} // namespace detail
+static uint32_t ByteArrayChecksum(const TVMFFIByteArray* bytes) {
+ TVM_FFI_ICHECK_NE(bytes, nullptr);
+ uint32_t checksum = 2166136261U;
+ for (size_t i = 0; i < bytes->size; ++i) {
+ checksum ^= static_cast<uint8_t>(bytes->data[i]);
+ checksum *= 16777619U;
+ }
+ return checksum;
+}
+
TVM_FFI_STATIC_INIT_BLOCK() {
namespace refl = tvm::ffi::reflection;
refl::GlobalDef()
@@ -123,6 +133,25 @@ TVM_FFI_STATIC_INIT_BLOCK() {
"tvmjs.testing.log_info_str",
[](ffi::PackedArgs args, ffi::Any* ret) { LOG(INFO) <<
args[0].cast<ffi::String>(); })
.def("tvmjs.testing.add_one", [](int x) { return x + 1; })
+ .def_packed("tvmjs.testing.check_byte_array_across_callbacks",
+ [](ffi::PackedArgs args, ffi::Any* ret) {
+ TVMFFIByteArray* bytes = args[0].cast<TVMFFIByteArray*>();
+ uint32_t expected = ByteArrayChecksum(bytes);
+ (args[1].cast<ffi::Function>())();
+ if (ByteArrayChecksum(bytes) != expected) {
+ *ret = int64_t{1};
+ return;
+ }
+ (args[1].cast<ffi::Function>())();
+ *ret = static_cast<int64_t>(ByteArrayChecksum(bytes) !=
expected) * 2;
+ })
+ .def_packed("tvmjs.testing.return_bytes_across_callbacks",
+ [](ffi::PackedArgs args, ffi::Any* ret) {
+ TVMFFIByteArray* bytes = args[0].cast<TVMFFIByteArray*>();
+ *ret = ffi::Bytes(bytes->data, bytes->size);
+ (args[1].cast<ffi::Function>())();
+ (args[1].cast<ffi::Function>())();
+ })
.def_packed("tvmjs.testing.wrap_callback", [](ffi::PackedArgs args,
ffi::Any* ret) {
ffi::Function pf = args[0].cast<ffi::Function>();
*ret = ffi::TypedFunction<void()>([pf]() { pf(); });
diff --git a/web/src/asyncify.ts b/web/src/asyncify.ts
index 6074a559e0..8839643421 100644
--- a/web/src/asyncify.ts
+++ b/web/src/asyncify.ts
@@ -146,7 +146,10 @@ export class AsyncifyHandler {
* @param func The input function
* @returns The wrapped async function
*/
- wrapExport(func: (...args: Array<any>) => any): (...args: Array<any>) =>
Promise<any> {
+ wrapExport(
+ func: (...args: Array<any>) => any,
+ onSuspend?: () => void
+ ): (...args: Array<any>) => Promise<any> {
return async (...args: Array<any>) => {
assert(this.getState() == AsyncifyStateKind.None);
@@ -159,6 +162,11 @@ export class AsyncifyHandler {
// the if (isPromise(value)) condition in wrapImport
while (this.getState() == AsyncifyStateKind.Unwinding) {
this.stopUnwind();
+ // The stack is unwound and no other JavaScript has run yet, so the
+ // caller can still take references before the await below.
+ if (onSuspend !== undefined) {
+ onSuspend();
+ }
// try to resolve the promise that the internal requested
// we then store it into the temp value in storedValueBeforeRewind
// which then get passed onto the function(see wrapImport)
diff --git a/web/src/memory.ts b/web/src/memory.ts
index 290826d6a9..d817266feb 100644
--- a/web/src/memory.ts
+++ b/web/src/memory.ts
@@ -21,10 +21,38 @@
*/
import { Pointer, PtrOffset, SizeOf } from "./ctypes";
import { Disposable } from "./types";
-import { assert, StringToUint8Array } from "./support";
+import { StringToUint8Array } from "./support";
import * as ctypes from "./ctypes";
+const CALL_STACK_CACHE_LIMIT_BYTES = 64 * 1024;
+// TVMWasmAllocSpace takes a signed int and rounds `(size + 7) / 8` in C++.
+// Keep the input at the largest aligned value that cannot overflow that sum.
+const MAX_WASM_ALLOCATION_BYTES = 0x7ffffff8;
+
+export interface WasmByteArraySource {
+ buffer: ArrayBuffer | SharedArrayBuffer;
+ byteOffset: number;
+ byteLength: number;
+}
+
+class WasmAllocation implements Disposable {
+ private ptr: Pointer;
+ private freeSpace: ctypes.FTVMWasmFreeSpace;
+
+ constructor(ptr: Pointer, freeSpace: ctypes.FTVMWasmFreeSpace) {
+ this.ptr = ptr;
+ this.freeSpace = freeSpace;
+ }
+
+ dispose(): void {
+ if (this.ptr !== 0) {
+ this.freeSpace(this.ptr);
+ this.ptr = 0;
+ }
+ }
+}
+
/**
* Wasm Memory wrapper to perform JS side raw memory access.
*/
@@ -347,6 +375,9 @@ export class CachedCallStack implements Disposable {
this.cFreeSpace = freeSpace;
this.buffer = new ArrayBuffer(initCallStackSize);
this.basePtr = this.cAllocSpace(initCallStackSize);
+ if (this.basePtr === 0) {
+ throw new Error(`Cannot allocate ${initCallStackSize} bytes in Wasm
memory`);
+ }
this.viewU8 = new Uint8Array(this.buffer);
this.viewI32 = new Int32Array(this.buffer);
this.viewU32 = new Uint32Array(this.buffer);
@@ -355,6 +386,7 @@ export class CachedCallStack implements Disposable {
}
dispose(): void {
+ this.reset();
if (this.basePtr != 0) {
this.cFreeSpace(this.basePtr);
this.basePtr = 0;
@@ -365,7 +397,7 @@ export class CachedCallStack implements Disposable {
*/
reset(): void {
this.stackTop = 0;
- assert(this.addressToSetTargetValue.length === 0);
+ this.addressToSetTargetValue.length = 0;
while (this.tempArgs.length != 0) {
(this.tempArgs.pop() as Disposable).dispose();
}
@@ -397,24 +429,38 @@ export class CachedCallStack implements Disposable {
*/
allocRawBytes(nbytes: number): PtrOffset {
// always aligns to 64bit
- nbytes = ((nbytes + 7) >> 3) << 3;
+ nbytes = this.alignToEight(nbytes);
+
+ const requiredSize = this.checkedAdd(this.stackTop, nbytes);
- if (this.stackTop + nbytes > this.buffer.byteLength) {
- const newSize = Math.max(
+ if (requiredSize > this.buffer.byteLength) {
+ const doubledSize = Math.min(
this.buffer.byteLength * 2,
- this.stackTop + nbytes
+ MAX_WASM_ALLOCATION_BYTES
);
- const oldU8 = this.viewU8;
- this.buffer = new ArrayBuffer(newSize);
- this.updateViews();
- this.viewU8.set(oldU8);
- if (this.basePtr != 0) {
- this.cFreeSpace(this.basePtr);
+ let newSize = Math.max(doubledSize, requiredSize);
+ if (this.buffer.byteLength < CALL_STACK_CACHE_LIMIT_BYTES &&
+ requiredSize <= CALL_STACK_CACHE_LIMIT_BYTES) {
+ newSize = Math.min(newSize, CALL_STACK_CACHE_LIMIT_BYTES);
+ } else if (this.buffer.byteLength <= CALL_STACK_CACHE_LIMIT_BYTES &&
+ requiredSize > CALL_STACK_CACHE_LIMIT_BYTES) {
+ // Headers and non-byte arguments may cross the soft cache cap. Grow
+ // only to the requested size instead of doubling past the cap.
+ newSize = requiredSize;
}
- this.basePtr = this.cAllocSpace(newSize);
+ const newBuffer = new ArrayBuffer(newSize);
+ new Uint8Array(newBuffer).set(this.viewU8);
+ const newBasePtr = this.cAllocSpace(newSize);
+ if (newBasePtr === 0) {
+ throw new Error(`Cannot allocate ${newSize} bytes in Wasm memory`);
+ }
+ this.cFreeSpace(this.basePtr);
+ this.basePtr = newBasePtr;
+ this.buffer = newBuffer;
+ this.updateViews();
}
const retOffset = this.stackTop;
- this.stackTop += nbytes;
+ this.stackTop = requiredSize;
return retOffset;
}
@@ -516,15 +562,99 @@ export class CachedCallStack implements Disposable {
* @param offset The offset to set ot data pointer.
* @param data The string content.
*/
- allocThenSetArgBytes(offset: PtrOffset, data: Uint8Array): void {
+ allocThenSetArgBytes(
+ offset: PtrOffset,
+ data: Uint8Array,
+ wasmSource?: WasmByteArraySource
+ ): void {
+ if (wasmSource === undefined && data.buffer === this.memory.memory.buffer)
{
+ wasmSource = {
+ buffer: data.buffer,
+ byteOffset: data.byteOffset,
+ byteLength: data.byteLength,
+ };
+ }
// Note: size of size_t equals sizeof ptr.
+ const dataLength = wasmSource?.byteLength ?? data.length;
+ const headerSize = this.alignToEight(this.memory.sizeofPtr() * 2);
+ const inlineDataSize = this.alignToEight(dataLength);
+ const cacheSizeWithHeader = this.checkedAdd(this.stackTop, headerSize);
+ const keepDataInline = cacheSizeWithHeader <= CALL_STACK_CACHE_LIMIT_BYTES
&&
+ inlineDataSize <= CALL_STACK_CACHE_LIMIT_BYTES - cacheSizeWithHeader;
const headerOffset = this.allocRawBytes(this.memory.sizeofPtr() * 2);
- const dataOffset = this.allocRawBytes(data.length);
- this.storeRawBytes(dataOffset, data);
- this.storeUSize(headerOffset + this.memory.sizeofPtr(), data.length);
+ this.storeUSize(headerOffset + this.memory.sizeofPtr(), dataLength);
this.addressToSetTargetValue.push([offset, headerOffset]);
- this.addressToSetTargetValue.push([headerOffset, dataOffset]);
+ if (keepDataInline) {
+ const dataOffset = this.allocRawBytes(dataLength);
+ this.storeRawBytes(dataOffset, this.resolveByteSource(data, wasmSource));
+ this.addressToSetTargetValue.push([headerOffset, dataOffset]);
+ } else {
+ const dataPtr = dataLength === 0 ? 0 : this.allocTransientBytes(data,
wasmSource);
+ this.storePtr(headerOffset, dataPtr);
+ }
+ }
+
+ /** Allocate bytes directly in Wasm memory for the duration of one FFI call.
*/
+ private allocTransientBytes(
+ data: Uint8Array,
+ wasmSource?: WasmByteArraySource
+ ): Pointer {
+ const dataLength = wasmSource?.byteLength ?? data.length;
+ this.checkAllocationSize(dataLength);
+
+ const dataPtr = this.cAllocSpace(dataLength);
+ if (dataPtr === 0) {
+ throw new Error(`Cannot allocate ${dataLength} bytes in Wasm memory`);
+ }
+
+ const allocation = new WasmAllocation(dataPtr, this.cFreeSpace);
+ this.tempArgs.push(allocation);
+ try {
+ this.memory.storeRawBytes(dataPtr, this.resolveByteSource(data,
wasmSource));
+ } catch (err) {
+ this.tempArgs.pop();
+ allocation.dispose();
+ throw err;
+ }
+ return dataPtr;
+ }
+
+ private resolveByteSource(
+ data: Uint8Array,
+ wasmSource?: WasmByteArraySource
+ ): Uint8Array {
+ if (wasmSource !== undefined && wasmSource.buffer !==
this.memory.memory.buffer) {
+ return new Uint8Array(
+ this.memory.memory.buffer,
+ wasmSource.byteOffset,
+ wasmSource.byteLength
+ );
+ }
+ return data;
+ }
+
+ private alignToEight(nbytes: number): number {
+ this.checkAllocationSize(nbytes);
+ const remainder = nbytes % 8;
+ const aligned = remainder === 0 ? nbytes : nbytes + 8 - remainder;
+ this.checkAllocationSize(aligned);
+ return aligned;
+ }
+
+ private checkedAdd(lhs: number, rhs: number): number {
+ const result = lhs + rhs;
+ this.checkAllocationSize(result);
+ return result;
+ }
+
+ private checkAllocationSize(nbytes: number): void {
+ if (!Number.isSafeInteger(nbytes) || nbytes < 0 || nbytes >
MAX_WASM_ALLOCATION_BYTES) {
+ throw new RangeError(
+ `Wasm allocation size must be an integer between 0 and
${MAX_WASM_ALLOCATION_BYTES}, ` +
+ `but received ${nbytes}`
+ );
+ }
}
/**
diff --git a/web/src/runtime.ts b/web/src/runtime.ts
index 996d7adc33..d615f2bbbd 100644
--- a/web/src/runtime.ts
+++ b/web/src/runtime.ts
@@ -22,7 +22,7 @@
*/
import { Pointer, PtrOffset, SizeOf, TypeIndex } from "./ctypes";
import { Disposable } from "./types";
-import { Memory, CachedCallStack } from "./memory";
+import { Memory, CachedCallStack, WasmByteArraySource } from "./memory";
import {
assert,
StringToUint8Array,
@@ -507,6 +507,17 @@ class PackedFuncCell extends TVMObject {
}
}
+interface PackedCallFrame {
+ cell: PackedFuncCell;
+ /** Extra reference taken once the call suspends in Asyncify. */
+ retainedCell?: PackedFuncCell;
+ stack: CachedCallStack;
+ argsOffset: PtrOffset;
+ numArgs: number;
+ retOffset: PtrOffset;
+ released: boolean;
+}
+
/**
* Tensor( n-dimnesional array).
*/
@@ -918,6 +929,7 @@ export class Instance implements Disposable {
private objFactory: Map<number, FObjectConstructor>;
private ctx: RuntimeContext;
private asyncifyHandler: AsyncifyHandler;
+ private asyncifyCallInProgress = false;
private initProgressCallback: Array<InitProgressCallback> = [];
private rng: LinearCongruentialGenerator;
private deviceLostIsError = true; // whether device.lost is due to actual
error or dispose()
@@ -1071,9 +1083,11 @@ export class Instance implements Disposable {
*/
withNewScope<T>(action: () => T): T {
this.beginScope();
- const val = action();
- this.endScope();
- return val;
+ try {
+ return action();
+ } finally {
+ this.endScope();
+ }
}
/**
@@ -1157,16 +1171,19 @@ export class Instance implements Disposable {
const ioverride = override ? 1 : 0;
const stack = this.lib.getOrAllocCallStack();
- const nameOffset = stack.allocByteArrayForString(name);
- stack.commitToWasmMemory();
- this.lib.checkCall(
- (this.lib.exports.TVMFFIFunctionSetGlobal as
ctypes.FTVMFFIFunctionSetGlobal)(
- stack.ptrFromOffset(nameOffset),
- packedFunc._tvmPackedCell.getHandle(),
- ioverride
- )
- );
- this.lib.recycleCallStack(stack);
+ try {
+ const nameOffset = stack.allocByteArrayForString(name);
+ stack.commitToWasmMemory();
+ this.lib.checkCall(
+ (this.lib.exports.TVMFFIFunctionSetGlobal as
ctypes.FTVMFFIFunctionSetGlobal)(
+ stack.ptrFromOffset(nameOffset),
+ packedFunc._tvmPackedCell.getHandle(),
+ ioverride
+ )
+ );
+ } finally {
+ this.lib.recycleCallStack(stack);
+ }
});
}
@@ -1192,20 +1209,24 @@ export class Instance implements Disposable {
autoAttachToScope = true,
): PackedFunc | undefined {
const stack = this.lib.getOrAllocCallStack();
- const nameOffset = stack.allocByteArrayForString(name);
- const outOffset = stack.allocPtrArray(1);
- const outPtr = stack.ptrFromOffset(outOffset);
+ let handle: Pointer;
+ try {
+ const nameOffset = stack.allocByteArrayForString(name);
+ const outOffset = stack.allocPtrArray(1);
+ const outPtr = stack.ptrFromOffset(outOffset);
- stack.commitToWasmMemory(outOffset);
+ stack.commitToWasmMemory(outOffset);
- this.lib.checkCall(
- (this.exports.TVMFFIFunctionGetGlobal as
ctypes.FTVMFFIFunctionGetGlobal)(
- stack.ptrFromOffset(nameOffset),
- outPtr
- )
- );
- const handle = this.memory.loadPointer(outPtr);
- this.lib.recycleCallStack(stack);
+ this.lib.checkCall(
+ (this.exports.TVMFFIFunctionGetGlobal as
ctypes.FTVMFFIFunctionGetGlobal)(
+ stack.ptrFromOffset(nameOffset),
+ outPtr
+ )
+ );
+ handle = this.memory.loadPointer(outPtr);
+ } finally {
+ this.lib.recycleCallStack(stack);
+ }
if (handle === 0) {
return undefined;
}
@@ -1900,20 +1921,22 @@ export class Instance implements Disposable {
typeKey: string
): number {
const stack = this.lib.getOrAllocCallStack();
- const typeKeyOffset = stack.allocByteArrayForString(typeKey);
- const outOffset = stack.allocPtrArray(1);
- const outPtr = stack.ptrFromOffset(outOffset);
+ try {
+ const typeKeyOffset = stack.allocByteArrayForString(typeKey);
+ const outOffset = stack.allocPtrArray(1);
+ const outPtr = stack.ptrFromOffset(outOffset);
- stack.commitToWasmMemory(outOffset);
- this.lib.checkCall(
- (this.lib.exports.TVMFFITypeKeyToIndex as ctypes.FTVMFFITypeKeyToIndex)(
- stack.ptrFromOffset(typeKeyOffset),
- outPtr
- )
- );
- const typeIndex = this.memory.loadU32(outPtr);
- this.lib.recycleCallStack(stack);
- return typeIndex;
+ stack.commitToWasmMemory(outOffset);
+ this.lib.checkCall(
+ (this.lib.exports.TVMFFITypeKeyToIndex as
ctypes.FTVMFFITypeKeyToIndex)(
+ stack.ptrFromOffset(typeKeyOffset),
+ outPtr
+ )
+ );
+ return this.memory.loadU32(outPtr);
+ } finally {
+ this.lib.recycleCallStack(stack);
+ }
}
/**
@@ -1950,8 +1973,53 @@ export class Instance implements Disposable {
* @returns The wrapped AsyncPackedFunc
*/
wrapAsyncifyPackedFunc(func: PackedFunc): AsyncPackedFunc {
- const asyncFunc = this.asyncifyHandler.wrapExport(func) as AsyncPackedFunc;
- asyncFunc.dispose = func.dispose;
+ let callInProgress = false;
+ let disposeRequested = false;
+ const asyncFunc = (async (...args: Array<any>): Promise<any> => {
+ if (this.asyncifyCallInProgress) {
+ throw new Error("Another Asyncify packed function is already running");
+ }
+ if (disposeRequested || func._tvmPackedCell.getHandle(false) === 0) {
+ throw new Error("Asyncify packed function has already been disposed");
+ }
+
+ callInProgress = true;
+ this.asyncifyCallInProgress = true;
+ let frame: PackedCallFrame | undefined;
+ try {
+ frame = this.preparePackedCall(func._tvmPackedCell, args);
+ // Every rewind must enter TVMFFIFunctionCall with the same argument
and
+ // return storage. In particular, the Wasm stack may retain pointers
+ // into transient byte arguments while its execution is suspended.
+ const run = this.asyncifyHandler.wrapExport(
+ () => this.invokePackedCall(frame as PackedCallFrame),
+ () => this.retainPackedCall(frame as PackedCallFrame)
+ );
+ return await run();
+ } finally {
+ try {
+ if (frame !== undefined) {
+ this.releasePackedCall(frame);
+ }
+ } finally {
+ callInProgress = false;
+ this.asyncifyCallInProgress = false;
+ if (disposeRequested) {
+ func.dispose();
+ }
+ }
+ }
+ }) as AsyncPackedFunc;
+ asyncFunc.dispose = (): void => {
+ if (func._tvmPackedCell.getHandle(false) === 0 || disposeRequested) {
+ return;
+ }
+ if (callInProgress) {
+ disposeRequested = true;
+ } else {
+ func.dispose();
+ }
+ };
asyncFunc._tvmPackedCell = func._tvmPackedCell;
return asyncFunc;
}
@@ -2213,18 +2281,20 @@ export class Instance implements Disposable {
this.env.packedCFuncTable[findex] = func;
const stack = this.lib.getOrAllocCallStack();
- const outOffset = stack.allocPtrArray(1);
- const outPtr = stack.ptrFromOffset(outOffset);
- this.lib.checkCall(
- (this.exports
- .TVMFFIWasmFunctionCreate as ctypes.FTVMFFIWasmFunctionCreate)(
- findex,
- outPtr
- )
- );
- const ret = this.makePackedFunc(this.memory.loadPointer(outPtr));
- this.lib.recycleCallStack(stack);
- return ret;
+ try {
+ const outOffset = stack.allocPtrArray(1);
+ const outPtr = stack.ptrFromOffset(outOffset);
+ this.lib.checkCall(
+ (this.exports
+ .TVMFFIWasmFunctionCreate as ctypes.FTVMFFIWasmFunctionCreate)(
+ findex,
+ outPtr
+ )
+ );
+ return this.makePackedFunc(this.memory.loadPointer(outPtr));
+ } finally {
+ this.lib.recycleCallStack(stack);
+ }
}
/**
@@ -2239,6 +2309,7 @@ export class Instance implements Disposable {
stack: CachedCallStack,
args: Array<any>,
packedArgs: PtrOffset,
+ wasmByteSources?: Array<WasmByteArraySource | undefined>,
): void {
for (let i = 0; i < args.length; ++i) {
let val = args[i];
@@ -2300,7 +2371,7 @@ export class Instance implements Disposable {
stack.allocThenSetArgString(argValueOffset, val);
} else if (val instanceof Uint8Array) {
stack.storeI32(argTypeIndexOffset, TypeIndex.kTVMFFIByteArrayPtr);
- stack.allocThenSetArgBytes(argValueOffset, val);
+ stack.allocThenSetArgBytes(argValueOffset, val, wasmByteSources?.[i]);
} else if (val instanceof Function) {
val = this.toPackedFuncInternal(val, false);
stack.tempArgs.push(val);
@@ -2326,98 +2397,100 @@ export class Instance implements Disposable {
numArgs: number,
ret: Pointer
): number => {
- const jsArgs = [];
- // use scope to track js values.
- this.ctx.beginScope();
- for (let i = 0; i < numArgs; ++i) {
- const argPtr = packedArgs + i * SizeOf.TVMFFIAny;
- const typeIndex = lib.memory.loadI32(argPtr);
-
- if (typeIndex >= TypeIndex.kTVMFFIRawStr) {
- // NOTE: the following code have limitations in asyncify mode.
- // The reason is that the TVMFFIAnyViewToOwnedAny will simply
- // get skipped during the rewinding process, causing memory failure
- if (!this.asyncifyHandler.isNormalStackState()) {
- throw Error("Cannot handle str/object argument callback in
asyncify mode");
+ let rv: any;
+ try {
+ // use scope to track js values.
+ this.ctx.beginScope();
+ try {
+ const jsArgs = [];
+ for (let i = 0; i < numArgs; ++i) {
+ const argPtr = packedArgs + i * SizeOf.TVMFFIAny;
+ const typeIndex = lib.memory.loadI32(argPtr);
+
+ if (typeIndex >= TypeIndex.kTVMFFIRawStr) {
+ // NOTE: the following code have limitations in asyncify mode.
+ // The reason is that the TVMFFIAnyViewToOwnedAny will simply
+ // get skipped during the rewinding process, causing memory
failure
+ if (!this.asyncifyHandler.isNormalStackState()) {
+ throw Error("Cannot handle str/object argument callback in
asyncify mode");
+ }
+ lib.checkCall(
+ (lib.exports.TVMFFIAnyViewToOwnedAny as
ctypes.FTVMFFIAnyViewToOwnedAny)(
+ argPtr,
+ argPtr
+ )
+ );
+ }
+ jsArgs.push(this.retValueToJS(argPtr, true));
}
- lib.checkCall(
- (lib.exports.TVMFFIAnyViewToOwnedAny as
ctypes.FTVMFFIAnyViewToOwnedAny)(
- argPtr,
- argPtr
- )
- );
+ rv = func(...jsArgs);
+ } finally {
+ // recycle all js object values created for the callback.
+ this.ctx.endScope();
}
- jsArgs.push(this.retValueToJS(argPtr, true));
- }
- let rv: any;
- try {
- rv = func(...jsArgs);
+ if (rv !== undefined && rv !== null) {
+ const wasmByteSources = this.captureWasmByteSources([rv]);
+ const stack = lib.getOrAllocCallStack();
+ try {
+ const argOffset = stack.allocRawBytes(SizeOf.TVMFFIAny);
+ this.setPackedArguments(stack, [rv], argOffset, wasmByteSources);
+ stack.commitToWasmMemory();
+ const argPtr = stack.ptrFromOffset(argOffset);
+ lib.checkCall(
+ (lib.exports.TVMFFIAnyViewToOwnedAny as
ctypes.FTVMFFIAnyViewToOwnedAny)(
+ argPtr,
+ ret
+ )
+ );
+ } finally {
+ lib.recycleCallStack(stack);
+ }
+ }
+ return 0;
} catch (error) {
- // error handling
- // store error via SetLastError
- this.ctx.endScope();
- const errKind = "JSCallbackError"
- const errMsg = error.message;
- const stack = lib.getOrAllocCallStack();
- const errKindOffset = stack.allocRawBytes(errKind.length + 1);
- stack.storeRawBytes(errKindOffset, StringToUint8Array(errKind));
- const errMsgOffset = stack.allocRawBytes(errMsg.length + 1);
- stack.storeRawBytes(errMsgOffset, StringToUint8Array(errMsg));
- stack.commitToWasmMemory();
- (this.lib.exports.TVMFFIErrorSetRaisedFromCStr as
ctypes.FTVMFFIErrorSetRaisedFromCStr)(
- stack.ptrFromOffset(errKindOffset),
- stack.ptrFromOffset(errMsgOffset)
- );
- this.lib.recycleCallStack(stack);
+ try {
+ this.setCallbackError(error);
+ } catch (reportError) {
+ this.env.logger(
+ `Failed to report JS callback error: ${String(reportError)}`
+ );
+ }
return -1;
}
-
- // normal return path
- // recycle all js object value in function unless we want to retain them.
- this.ctx.endScope();
- if (rv !== undefined && rv !== null) {
- const stack = lib.getOrAllocCallStack();
- const argOffset = stack.allocRawBytes(SizeOf.TVMFFIAny);
- this.setPackedArguments(stack, [rv], argOffset);
- stack.commitToWasmMemory();
- const argPtr = stack.ptrFromOffset(argOffset);
- lib.checkCall(
- (lib.exports.TVMFFIAnyViewToOwnedAny as
ctypes.FTVMFFIAnyViewToOwnedAny)(
- argPtr,
- ret
- )
- );
- lib.recycleCallStack(stack);
- }
- return 0;
};
}
- private makePackedFunc(handle: Pointer): PackedFunc {
- const cell = new PackedFuncCell(handle, this.lib, this.ctx);
- const packedFunc = (...args: any): any => {
- const stack = this.lib.getOrAllocCallStack();
- const argsOffset = stack.allocRawBytes(SizeOf.TVMFFIAny * args.length);
- this.setPackedArguments(stack, args, argsOffset);
- const retOffset = stack.allocRawBytes(SizeOf.TVMFFIAny);
- // pre-store the result to be null
- stack.storeI32(retOffset, TypeIndex.kTVMFFINone);
- // clear off the extra zero padding before ptr storage
- stack.storeI32(retOffset + SizeOf.I32, 0);
+ private setCallbackError(error: unknown): void {
+ const errKind = "JSCallbackError";
+ const errMsg = error instanceof Error ? error.message : String(error);
+ const errKindBytes = StringToUint8Array(errKind);
+ const errMsgBytes = StringToUint8Array(errMsg);
+ const stack = this.lib.getOrAllocCallStack();
+ try {
+ const errKindOffset = stack.allocRawBytes(errKindBytes.byteLength);
+ stack.storeRawBytes(errKindOffset, errKindBytes);
+ const errMsgOffset = stack.allocRawBytes(errMsgBytes.byteLength);
+ stack.storeRawBytes(errMsgOffset, errMsgBytes);
stack.commitToWasmMemory();
- this.lib.checkCall(
- (this.exports.TVMFFIFunctionCall as ctypes.FTVMFFIFunctionCall)(
- cell.getHandle(),
- stack.ptrFromOffset(argsOffset),
- args.length,
- stack.ptrFromOffset(retOffset)
- )
+ (this.lib.exports.TVMFFIErrorSetRaisedFromCStr as
ctypes.FTVMFFIErrorSetRaisedFromCStr)(
+ stack.ptrFromOffset(errKindOffset),
+ stack.ptrFromOffset(errMsgOffset)
);
-
- const ret = this.retValueToJS(stack.ptrFromOffset(retOffset), false);
+ } finally {
this.lib.recycleCallStack(stack);
- return ret;
+ }
+ }
+
+ private makePackedFunc(handle: Pointer): PackedFunc {
+ const cell = new PackedFuncCell(handle, this.lib, this.ctx);
+ const packedFunc = (...args: any): any => {
+ const frame = this.preparePackedCall(cell, args);
+ try {
+ return this.invokePackedCall(frame);
+ } finally {
+ this.releasePackedCall(frame);
+ }
};
// Attach attributes to the function type.
// This is because javascript do not allow us to overload call.
@@ -2429,6 +2502,102 @@ export class Instance implements Disposable {
return ret as PackedFunc;
}
+ private preparePackedCall(cell: PackedFuncCell, args: Array<any>):
PackedCallFrame {
+ // Capture Wasm-backed views before either call-stack or transient
+ // allocation can grow memory and detach them.
+ const wasmByteSources = this.captureWasmByteSources(args);
+ const stack = this.lib.getOrAllocCallStack();
+ try {
+ const argsOffset = stack.allocRawBytes(SizeOf.TVMFFIAny * args.length);
+ this.setPackedArguments(stack, args, argsOffset, wasmByteSources);
+ const retOffset = stack.allocRawBytes(SizeOf.TVMFFIAny);
+ // pre-store the result to be null
+ stack.storeI32(retOffset, TypeIndex.kTVMFFINone);
+ // clear off the extra zero padding before ptr storage
+ stack.storeI32(retOffset + SizeOf.I32, 0);
+ stack.commitToWasmMemory();
+ return {
+ cell,
+ stack,
+ argsOffset,
+ numArgs: args.length,
+ retOffset,
+ released: false,
+ };
+ } catch (error) {
+ this.lib.recycleCallStack(stack);
+ throw error;
+ }
+ }
+
+ /**
+ * Take an extra reference on the function once a call has suspended.
+ *
+ * While the Wasm stack is unwound, other JavaScript can dispose the
+ * function through another wrapper. The frame keeps the callee alive
+ * until the rewound call finishes. Synchronous calls do not take it.
+ */
+ private retainPackedCall(frame: PackedCallFrame): void {
+ if (frame.released || frame.retainedCell !== undefined) {
+ return;
+ }
+ const handle = frame.cell.getHandle();
+ this.lib.checkCall(
+ (this.exports.TVMFFIObjectIncRef as ctypes.FTVMFFIObjectIncRef)(handle)
+ );
+ frame.retainedCell = new PackedFuncCell(handle, this.lib, this.ctx);
+ }
+
+ private invokePackedCall(frame: PackedCallFrame): any {
+ if (frame.released) {
+ throw new Error("Cannot invoke a released packed-call frame");
+ }
+ this.lib.checkCall(
+ (this.exports.TVMFFIFunctionCall as ctypes.FTVMFFIFunctionCall)(
+ (frame.retainedCell ?? frame.cell).getHandle(),
+ frame.stack.ptrFromOffset(frame.argsOffset),
+ frame.numArgs,
+ frame.stack.ptrFromOffset(frame.retOffset)
+ )
+ );
+ if (!this.asyncifyHandler.isNormalStackState()) {
+ return undefined;
+ }
+ return this.retValueToJS(frame.stack.ptrFromOffset(frame.retOffset),
false);
+ }
+
+ private releasePackedCall(frame: PackedCallFrame): void {
+ if (!frame.released) {
+ frame.released = true;
+ try {
+ this.lib.recycleCallStack(frame.stack);
+ } finally {
+ frame.retainedCell?.dispose();
+ }
+ }
+ }
+
+ private captureWasmByteSources(
+ args: Array<any>
+ ): Array<WasmByteArraySource | undefined> | undefined {
+ const wasmBuffer = this.memory.memory.buffer;
+ let sources: Array<WasmByteArraySource | undefined> | undefined;
+ for (let i = 0; i < args.length; ++i) {
+ const arg = args[i];
+ if (arg instanceof Uint8Array && arg.buffer === wasmBuffer) {
+ if (sources === undefined) {
+ sources = new Array(args.length);
+ }
+ sources[i] = {
+ buffer: wasmBuffer,
+ byteOffset: arg.byteOffset,
+ byteLength: arg.byteLength,
+ };
+ }
+ }
+ return sources;
+ }
+
/**
* Creaye return value of the packed func. The value us auto-tracked for
dispose.
* @param resultAnyPtr The location of rvalue
diff --git a/web/tests/node/test_ffi_bytes.js b/web/tests/node/test_ffi_bytes.js
new file mode 100644
index 0000000000..8f7e2969ac
--- /dev/null
+++ b/web/tests/node/test_ffi_bytes.js
@@ -0,0 +1,289 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+const path = require("path");
+const fs = require("fs");
+jest.mock("../../src/tvmjs_runtime_wasi", () => ({
+ __esModule: true,
+ default: function MockWASI() {},
+}));
+const { Instance } = require("../../src/runtime");
+const { createPolyfillWASI } = require("../../dist/tvmjs.bundle");
+
+const wasmSource = fs.readFileSync(
+ path.join(__dirname, "../../dist/wasm/tvmjs_runtime.wasm")
+);
+
+let tvm;
+
+beforeAll(() => {
+ tvm = new Instance(
+ new WebAssembly.Module(wasmSource),
+ createPolyfillWASI()
+ );
+});
+
+afterAll(() => {
+ tvm.dispose();
+});
+
+function makeBytes(length) {
+ const bytes = new Uint8Array(length);
+ for (let i = 0; i < bytes.length; ++i) bytes[i] = i % 251;
+ return bytes;
+}
+
+function expectExactBytes(actual, expected) {
+ expect(actual).toBeInstanceOf(Uint8Array);
+ expect(actual).toEqual(expected);
+}
+
+
+test("PackedFunc large callback return supports reentrant calls", () => {
+ tvm.withNewScope(() => {
+ const echo = tvm.getGlobalFunc("testing.echo");
+ const input = makeBytes(64 * 1024 + 17);
+ const reentrantEcho = tvm.toPackedFunc((value) => {
+ const innerResult = echo(value);
+ expectExactBytes(innerResult, input);
+ return innerResult;
+ });
+
+ expectExactBytes(reentrantEcho(input), input);
+ expectExactBytes(reentrantEcho(input), input);
+ });
+});
+
+
+test("PackedFunc reports non-ASCII callback errors and remains reusable", ()
=> {
+ tvm.withNewScope(() => {
+ const message = "é".repeat(16);
+ const reportedMessage = Array.from(
+ new TextEncoder().encode(message),
+ (byte) => String.fromCharCode(byte)
+ ).join("");
+ const throwing = tvm.toPackedFunc(() => {
+ throw new Error(message);
+ });
+
+ expect(() => throwing()).toThrow(reportedMessage);
+
+ const echo = tvm.getGlobalFunc("testing.echo");
+ expect(echo(7)).toBe(7);
+ });
+});
+
+test("PackedFunc non-byte arguments do not allocate byte-source descriptors",
() => {
+ expect(tvm.captureWasmByteSources([1, "x", new Uint8Array(16)]))
+ .toBeUndefined();
+});
+
+test("PackedFunc captures Wasm-backed byte sources automatically", () => {
+ const ptr = tvm.exports.TVMWasmAllocSpace(32);
+ if (ptr === 0) {
+ throw new Error("Cannot allocate the Wasm-backed input");
+ }
+ try {
+ const input = new Uint8Array(tvm.memory.memory.buffer, ptr, 32);
+ const expected = makeBytes(input.length);
+ input.set(expected);
+ const capture = tvm.captureWasmByteSources;
+ tvm.captureWasmByteSources = function(args) {
+ const sources = capture.call(this, args);
+ this.memory.memory.grow(1);
+ return sources;
+ };
+ try {
+ tvm.withNewScope(() => {
+ const echo = tvm.getGlobalFunc("testing.echo");
+ expectExactBytes(echo(input), expected);
+ });
+ expect(input.byteLength).toBe(0);
+ } finally {
+ tvm.captureWasmByteSources = capture;
+ }
+ } finally {
+ tvm.exports.TVMWasmFreeSpace(ptr);
+ }
+});
+
+test("Asyncify keeps transient byte arguments alive across every rewind",
async () => {
+ if (!tvm.asyncifyEnabled()) {
+ throw new Error("The test runtime must be built with Asyncify enabled");
+ }
+
+ tvm.beginScope();
+ try {
+ const input = makeBytes(64 * 1024 + 17);
+ const heldPointers = [];
+ tvm.registerAsyncifyFunc("tvmjs.testing.clobber_transient_bytes", async ()
=> {
+ await Promise.resolve();
+ const ptr = tvm.exports.TVMWasmAllocSpace(input.byteLength);
+ if (ptr === 0) {
+ throw new Error("Cannot allocate the clobber buffer");
+ }
+ heldPointers.push(ptr);
+ tvm.memory.storeRawBytes(ptr, new
Uint8Array(input.byteLength).fill(0xa5));
+ }, true);
+
+ const callback =
tvm.getGlobalFunc("tvmjs.testing.clobber_transient_bytes");
+ const check = tvm.wrapAsyncifyPackedFunc(
+ tvm.getGlobalFunc("tvmjs.testing.check_byte_array_across_callbacks")
+ );
+ try {
+ const pending = check(input, callback);
+ check.dispose();
+ expect(await pending).toBe(0);
+ expect(heldPointers).toHaveLength(2);
+ expect(check._tvmPackedCell.getHandle(false)).toBe(0);
+ await expect(check(input, callback)).rejects.toThrow(
+ "Asyncify packed function has already been disposed"
+ );
+ } finally {
+ for (const ptr of heldPointers) {
+ tvm.exports.TVMWasmFreeSpace(ptr);
+ }
+ }
+ } finally {
+ tvm.endScope();
+ }
+});
+
+test("Asyncify rejects overlapping calls from different wrappers", async () =>
{
+ tvm.beginScope();
+ let release;
+ try {
+ const input = makeBytes(64 * 1024 + 17);
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ tvm.registerAsyncifyFunc("tvmjs.testing.wait_for_concurrency_test", async
() => {
+ await gate;
+ }, true);
+
+ const callback =
tvm.getGlobalFunc("tvmjs.testing.wait_for_concurrency_test");
+ const first = tvm.wrapAsyncifyPackedFunc(
+ tvm.getGlobalFunc("tvmjs.testing.check_byte_array_across_callbacks")
+ );
+ const second = tvm.wrapAsyncifyPackedFunc(
+ tvm.getGlobalFunc("tvmjs.testing.check_byte_array_across_callbacks")
+ );
+
+ const pending = first(input, callback);
+ await expect(second(input, callback)).rejects.toThrow(
+ "Another Asyncify packed function is already running"
+ );
+ release();
+ expect(await pending).toBe(0);
+ } finally {
+ if (release !== undefined) {
+ release();
+ }
+ tvm.endScope();
+ }
+});
+
+test("Asyncify call retains the function across alias disposal", async () => {
+ tvm.beginScope();
+ let release;
+ try {
+ const input = makeBytes(64 * 1024 + 17);
+ const gate = new Promise((resolve) => {
+ release = resolve;
+ });
+ tvm.registerAsyncifyFunc("tvmjs.testing.wait_for_alias_disposal", async ()
=> {
+ await gate;
+ }, true);
+
+ const callback =
tvm.getGlobalFunc("tvmjs.testing.wait_for_alias_disposal");
+ const original = tvm.getGlobalFunc(
+ "tvmjs.testing.check_byte_array_across_callbacks"
+ );
+ const active = tvm.wrapAsyncifyPackedFunc(original);
+ const alias = tvm.wrapAsyncifyPackedFunc(original);
+
+ const pending = active(input, callback);
+ alias.dispose();
+ expect(original._tvmPackedCell.getHandle(false)).toBe(0);
+ release();
+ expect(await pending).toBe(0);
+ } finally {
+ if (release !== undefined) {
+ release();
+ }
+ tvm.endScope();
+ }
+});
+
+test("Asyncify clears call state when frame cleanup throws", async () => {
+ tvm.beginScope();
+ const releasePackedCall = tvm.releasePackedCall;
+ try {
+ const original = tvm.getGlobalFunc("testing.echo");
+ const wrapped = tvm.wrapAsyncifyPackedFunc(original);
+ tvm.releasePackedCall = function(frame) {
+ releasePackedCall.call(this, frame);
+ throw new Error("expected frame cleanup failure");
+ };
+
+ const pending = wrapped(1);
+ wrapped.dispose();
+ await expect(pending).rejects.toThrow("expected frame cleanup failure");
+ expect(original._tvmPackedCell.getHandle(false)).toBe(0);
+ expect(tvm.asyncifyCallInProgress).toBe(false);
+ } finally {
+ tvm.releasePackedCall = releasePackedCall;
+ tvm.endScope();
+ }
+});
+
+test("Asyncify does not consume an owned byte result before completion", async
() => {
+ if (!tvm.asyncifyEnabled()) {
+ throw new Error("The test runtime must be built with Asyncify enabled");
+ }
+
+ tvm.beginScope();
+ try {
+ const input = makeBytes(64 * 1024 + 17);
+ const heldPointers = [];
+ tvm.registerAsyncifyFunc("tvmjs.testing.clobber_owned_result", async () =>
{
+ await Promise.resolve();
+ const ptr = tvm.exports.TVMWasmAllocSpace(input.byteLength);
+ if (ptr === 0) {
+ throw new Error("Cannot allocate the clobber buffer");
+ }
+ heldPointers.push(ptr);
+ tvm.memory.storeRawBytes(ptr, new
Uint8Array(input.byteLength).fill(0x5a));
+ }, true);
+
+ const callback = tvm.getGlobalFunc("tvmjs.testing.clobber_owned_result");
+ const returnBytes = tvm.wrapAsyncifyPackedFunc(
+ tvm.getGlobalFunc("tvmjs.testing.return_bytes_across_callbacks")
+ );
+ try {
+ expectExactBytes(await returnBytes(input, callback), input);
+ expect(heldPointers).toHaveLength(2);
+ } finally {
+ for (const ptr of heldPointers) {
+ tvm.exports.TVMWasmFreeSpace(ptr);
+ }
+ }
+ } finally {
+ tvm.endScope();
+ }
+});
diff --git a/web/tests/node/test_memory.js b/web/tests/node/test_memory.js
index 1d39179d8e..fbc2776319 100644
--- a/web/tests/node/test_memory.js
+++ b/web/tests/node/test_memory.js
@@ -30,6 +30,42 @@ test("loadU64 reads unsigned values and rejects unsafe
integers", () => {
expect(() => memory.loadU64(8)).toThrow("Cannot represent uint64 value");
});
+const CACHE_LIMIT_BYTES = 64 * 1024;
+
+function createMockStack(allocImpl) {
+ const wasmMemory = new WebAssembly.Memory({ initial: 1 });
+ const memory = {
+ wasm32: true,
+ memory: wasmMemory,
+ sizeofPtr: () => 4,
+ storeRawBytes: jest.fn(),
+ };
+ const allocations = [];
+ const frees = [];
+ let nextPtr = 1024;
+ const alloc = jest.fn((size) => {
+ const ptr = allocImpl === undefined
+ ? (nextPtr += Math.max(size, 1)) - Math.max(size, 1)
+ : allocImpl(size, allocations.length);
+ allocations.push([ptr, size]);
+ return ptr;
+ });
+ const free = jest.fn((ptr) => frees.push(ptr));
+ return {
+ memory,
+ allocations,
+ frees,
+ alloc,
+ free,
+ stack: new CachedCallStack(memory, alloc, free),
+ };
+}
+
+function loadU32(bytes, offset) {
+ return new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
+ .getUint32(offset, true);
+}
+
test("loadRawBytes returns an owned copy", () => {
const wasmMemory = new WebAssembly.Memory({ initial: 1 });
const memory = new Memory(wasmMemory);
@@ -139,3 +175,201 @@ test("CachedCallStack commits a view of its cached
bytes", () => {
expect(Array.from(bytes)).toEqual([1, 2, 3, 4]);
expect(bytes.buffer).toBe(stack.buffer);
});
+
+test("CachedCallStack keeps an exactly 64 KiB projected byte argument inline",
() => {
+ const { stack, memory, allocations } = createMockStack();
+ const argsOffset = stack.allocRawBytes(16);
+ const bytes = new Uint8Array(CACHE_LIMIT_BYTES - 24);
+ bytes[0] = 3;
+ bytes[bytes.length - 1] = 9;
+
+ stack.allocThenSetArgBytes(argsOffset + 8, bytes);
+ stack.commitToWasmMemory();
+
+ expect(stack.buffer.byteLength).toBe(CACHE_LIMIT_BYTES);
+ expect(allocations.map(([, size]) => size)).toEqual([128,
CACHE_LIMIT_BYTES]);
+ expect(memory.storeRawBytes).toHaveBeenCalledTimes(1);
+ const committed = memory.storeRawBytes.mock.calls[0][1];
+ const headerPtr = loadU32(committed, argsOffset + 8);
+ const headerOffset = headerPtr - allocations[1][0];
+ const dataPtr = loadU32(committed, headerOffset);
+ const dataOffset = dataPtr - allocations[1][0];
+ expect(loadU32(committed, headerOffset + 4)).toBe(bytes.length);
+ expect(committed[dataOffset]).toBe(3);
+ expect(committed[dataOffset + bytes.length - 1]).toBe(9);
+});
+
+test("CachedCallStack stores an over-limit byte argument in one transient
allocation", () => {
+ const { stack, memory, allocations, frees } = createMockStack();
+ const argsOffset = stack.allocRawBytes(16);
+ const bytes = new Uint8Array(CACHE_LIMIT_BYTES - 23);
+ bytes.set([1, 2, 3, 4]);
+ bytes[bytes.length - 1] = 5;
+
+ stack.allocThenSetArgBytes(argsOffset + 8, bytes);
+ stack.commitToWasmMemory();
+
+ expect(stack.buffer.byteLength).toBe(128);
+ expect(allocations.map(([, size]) => size)).toEqual([128, bytes.length]);
+ expect(memory.storeRawBytes).toHaveBeenCalledTimes(2);
+ const [payloadPtr, payload] = memory.storeRawBytes.mock.calls[0];
+ const [basePtr, committed] = memory.storeRawBytes.mock.calls[1];
+ expect(payloadPtr).toBe(allocations[1][0]);
+ expect(payload).toBe(bytes);
+ const headerPtr = loadU32(committed, argsOffset + 8);
+ const headerOffset = headerPtr - basePtr;
+ expect(loadU32(committed, headerOffset)).toBe(payloadPtr);
+ expect(loadU32(committed, headerOffset + 4)).toBe(bytes.length);
+
+ stack.reset();
+ expect(frees).toEqual([payloadPtr]);
+ stack.reset();
+ expect(frees).toEqual([payloadPtr]);
+});
+
+test("CachedCallStack considers byte arguments collectively when applying the
cap", () => {
+ const { stack, memory, allocations, frees } = createMockStack();
+ const argsOffset = stack.allocRawBytes(32);
+ const first = new Uint8Array(40000).fill(7);
+ const second = new Uint8Array(30000).fill(11);
+
+ stack.allocThenSetArgBytes(argsOffset + 8, first);
+ stack.allocThenSetArgBytes(argsOffset + 24, second);
+ stack.commitToWasmMemory();
+
+ expect(stack.buffer.byteLength).toBeLessThanOrEqual(CACHE_LIMIT_BYTES);
+ expect(memory.storeRawBytes).toHaveBeenCalledTimes(2);
+ expect(memory.storeRawBytes.mock.calls[0][1]).toBe(second);
+ const transient = allocations.find(([, size]) => size === second.length);
+ expect(transient).toBeDefined();
+ stack.reset();
+ expect(frees).toContain(transient[0]);
+});
+
+
+test("CachedCallStack frees transient bytes after failed and repeated calls",
() => {
+ const { stack, memory, allocations, frees } = createMockStack();
+ const bytes = new Uint8Array(CACHE_LIMIT_BYTES);
+ memory.storeRawBytes.mockImplementationOnce(() => {
+ throw new Error("copy failed");
+ });
+
+ const argsOffset = stack.allocRawBytes(16);
+ expect(() => stack.allocThenSetArgBytes(argsOffset + 8, bytes))
+ .toThrow("copy failed");
+ const failedPtr = allocations[1][0];
+ expect(frees).toEqual([failedPtr]);
+ stack.reset();
+ expect(frees).toEqual([failedPtr]);
+
+ for (let i = 0; i < 2; ++i) {
+ const offset = stack.allocRawBytes(16);
+ stack.allocThenSetArgBytes(offset + 8, bytes);
+ stack.commitToWasmMemory();
+ const transientPtr = allocations[2 + i][0];
+ stack.reset();
+ expect(frees).toContain(transientPtr);
+ }
+});
+
+test("CachedCallStack reset drops uncommitted fixups and dispose drains
allocations", () => {
+ const { stack, allocations, frees } = createMockStack();
+ const argsOffset = stack.allocRawBytes(16);
+ stack.allocThenSetArgBytes(argsOffset + 8, new
Uint8Array(CACHE_LIMIT_BYTES));
+ const transientPtr = allocations[1][0];
+
+ stack.reset();
+ expect(frees).toEqual([transientPtr]);
+ stack.dispose();
+ expect(frees).toContain(allocations[0][0]);
+ stack.dispose();
+ expect(frees.filter((ptr) => ptr === allocations[0][0])).toHaveLength(1);
+});
+
+test("CachedCallStack uses a null data pointer for an out-of-line empty
array", () => {
+ const { stack, memory, alloc } = createMockStack();
+ stack.allocRawBytes(CACHE_LIMIT_BYTES);
+ stack.allocThenSetArgBytes(8, new Uint8Array(0));
+ stack.commitToWasmMemory();
+
+ expect(alloc).not.toHaveBeenCalledWith(0);
+ const [basePtr, committed] = memory.storeRawBytes.mock.calls[0];
+ const headerOffset = loadU32(committed, 8) - basePtr;
+ expect(loadU32(committed, headerOffset)).toBe(0);
+ expect(loadU32(committed, headerOffset + 4)).toBe(0);
+});
+
+test("CachedCallStack rejects sizes that cannot be passed to the Wasm
allocator", () => {
+ const { stack } = createMockStack();
+ expect(() => stack.allocRawBytes(-1)).toThrow(RangeError);
+ expect(() => stack.allocRawBytes(1.5)).toThrow(RangeError);
+ expect(() => stack.allocRawBytes(0x7ffffff9)).toThrow(RangeError);
+ expect(() => stack.allocRawBytes(0x7fffffff)).toThrow(RangeError);
+ expect(() => stack.allocRawBytes(0x100000000)).toThrow(RangeError);
+});
+
+test("CachedCallStack survives a failed Wasm allocation", () => {
+ // Initial allocation fails: nothing to free.
+ const free = jest.fn();
+ expect(() => new CachedCallStack(
+ { wasm32: true, sizeofPtr: () => 4, storeRawBytes: jest.fn() },
+ () => 0,
+ free
+ )).toThrow("Cannot allocate 128 bytes");
+ expect(free).not.toHaveBeenCalled();
+
+ // Growth fails: the old allocation stays usable.
+ let mock = createMockStack((size, index) => index === 0 ? 1024 : 0);
+ expect(() => mock.stack.allocRawBytes(256)).toThrow("Cannot allocate 256
bytes");
+ expect(mock.stack.buffer.byteLength).toBe(128);
+ expect(mock.frees).toEqual([]);
+ expect(mock.stack.allocRawBytes(8)).toBe(0);
+ mock.stack.dispose();
+ expect(mock.frees).toEqual([1024]);
+
+ // Transient allocation fails: no copy, no dangling temp entry.
+ mock = createMockStack((size, index) => index === 0 ? 1024 : 0);
+ const argsOffset = mock.stack.allocRawBytes(16);
+ expect(() => mock.stack.allocThenSetArgBytes(
+ argsOffset + 8,
+ new Uint8Array(CACHE_LIMIT_BYTES)
+ )).toThrow(`Cannot allocate ${CACHE_LIMIT_BYTES} bytes`);
+ expect(mock.memory.storeRawBytes).not.toHaveBeenCalled();
+ expect(mock.stack.tempArgs).toHaveLength(0);
+ expect(mock.frees).toEqual([]);
+ mock.stack.dispose();
+ expect(mock.frees).toEqual([1024]);
+});
+
+test("CachedCallStack rebuilds a Wasm-backed input after allocation grows
memory", () => {
+ const wasmMemory = new WebAssembly.Memory({ initial: 2 });
+ const memory = new Memory(wasmMemory);
+ const frees = [];
+ let allocCount = 0;
+ const alloc = (size) => {
+ if (allocCount++ === 0) return 1024;
+ wasmMemory.grow(2);
+ return 150000;
+ };
+ const stack = new CachedCallStack(memory, alloc, (ptr) => frees.push(ptr));
+ const source = new Uint8Array(wasmMemory.buffer, 4096, CACHE_LIMIT_BYTES);
+ for (let i = 0; i < source.length; ++i) source[i] = i % 251;
+ const sourceLength = source.length;
+ const wasmSource = {
+ buffer: source.buffer,
+ byteOffset: source.byteOffset,
+ byteLength: source.byteLength,
+ };
+ const argsOffset = stack.allocRawBytes(16);
+
+ stack.allocThenSetArgBytes(argsOffset + 8, source, wasmSource);
+
+ const result = memory.loadRawBytes(150000, sourceLength);
+ expect(result[0]).toBe(0);
+ expect(result[250]).toBe(250);
+ expect(result[251]).toBe(0);
+ expect(result[result.length - 1]).toBe((result.length - 1) % 251);
+ expect(source.byteLength).toBe(0);
+ stack.reset();
+ expect(frees).toContain(150000);
+});