Skip to content

Commit f11d8b0

Browse files
committed
[Web] Retain packed functions across Asyncify calls
Asyncify can suspend a prepared call after the JavaScript wrapper has been disposed through another alias. Take an extra reference on the PackedFunc when the call first unwinds and release it with the frame, so the rewound call still has a live callee. Synchronous calls do not take the reference. Release the retained handle with the frame, including when cleanup fails.
1 parent e76c0f0 commit f11d8b0

4 files changed

Lines changed: 123 additions & 21 deletions

File tree

‎web/src/asyncify.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,10 @@ export class AsyncifyHandler {
146146
* @param func The input function
147147
* @returns The wrapped async function
148148
*/
149-
wrapExport(func: (...args: Array<any>) => any): (...args: Array<any>) => Promise<any> {
149+
wrapExport(
150+
func: (...args: Array<any>) => any,
151+
onSuspend?: () => void
152+
): (...args: Array<any>) => Promise<any> {
150153
return async (...args: Array<any>) => {
151154
assert(this.getState() == AsyncifyStateKind.None);
152155

@@ -159,6 +162,11 @@ export class AsyncifyHandler {
159162
// the if (isPromise(value)) condition in wrapImport
160163
while (this.getState() == AsyncifyStateKind.Unwinding) {
161164
this.stopUnwind();
165+
// The stack is unwound and no other JavaScript has run yet, so the
166+
// caller can still take references before the await below.
167+
if (onSuspend !== undefined) {
168+
onSuspend();
169+
}
162170
// try to resolve the promise that the internal requested
163171
// we then store it into the temp value in storedValueBeforeRewind
164172
// which then get passed onto the function(see wrapImport)

‎web/src/ctypes.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,11 @@ export type FTVMFFIWasmFunctionCreate = (
176176
*/
177177
export type FTVMFFIWasmFunctionDeleter = (self: Pointer) => void;
178178

179+
/**
180+
* int TVMFFIObjectIncRef(TVMFFIObjectHandle obj);
181+
*/
182+
export type FTVMFFIObjectIncRef = (obj: Pointer) => number;
183+
179184
/**
180185
* int TVMFFIObjectDecRef(TVMFFIObjectHandle obj);
181186
*/

‎web/src/runtime.ts‎

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -509,6 +509,8 @@ class PackedFuncCell extends TVMObject {
509509

510510
interface PackedCallFrame {
511511
cell: PackedFuncCell;
512+
/** Extra reference taken once the call suspends in Asyncify. */
513+
retainedCell?: PackedFuncCell;
512514
stack: CachedCallStack;
513515
argsOffset: PtrOffset;
514516
numArgs: number;
@@ -1975,17 +1977,21 @@ export class Instance implements Disposable {
19751977
// return storage. In particular, the Wasm stack may retain pointers
19761978
// into transient byte arguments while its execution is suspended.
19771979
const run = this.asyncifyHandler.wrapExport(
1978-
() => this.invokePackedCall(frame as PackedCallFrame)
1980+
() => this.invokePackedCall(frame as PackedCallFrame),
1981+
() => this.retainPackedCall(frame as PackedCallFrame)
19791982
);
19801983
return await run();
19811984
} finally {
1982-
if (frame !== undefined) {
1983-
this.releasePackedCall(frame);
1984-
}
1985-
callInProgress = false;
1986-
this.asyncifyCallInProgress = false;
1987-
if (disposeRequested) {
1988-
func.dispose();
1985+
try {
1986+
if (frame !== undefined) {
1987+
this.releasePackedCall(frame);
1988+
}
1989+
} finally {
1990+
callInProgress = false;
1991+
this.asyncifyCallInProgress = false;
1992+
if (disposeRequested) {
1993+
func.dispose();
1994+
}
19891995
}
19901996
}
19911997
}) as AsyncPackedFunc;
@@ -2498,13 +2504,31 @@ export class Instance implements Disposable {
24982504
}
24992505
}
25002506

2507+
/**
2508+
* Take an extra reference on the function once a call has suspended.
2509+
*
2510+
* While the Wasm stack is unwound, other JavaScript can dispose the
2511+
* function through another wrapper. The frame keeps the callee alive
2512+
* until the rewound call finishes. Synchronous calls do not take it.
2513+
*/
2514+
private retainPackedCall(frame: PackedCallFrame): void {
2515+
if (frame.released || frame.retainedCell !== undefined) {
2516+
return;
2517+
}
2518+
const handle = frame.cell.getHandle();
2519+
this.lib.checkCall(
2520+
(this.exports.TVMFFIObjectIncRef as ctypes.FTVMFFIObjectIncRef)(handle)
2521+
);
2522+
frame.retainedCell = new PackedFuncCell(handle, this.lib, this.ctx);
2523+
}
2524+
25012525
private invokePackedCall(frame: PackedCallFrame): any {
25022526
if (frame.released) {
25032527
throw new Error("Cannot invoke a released packed-call frame");
25042528
}
25052529
this.lib.checkCall(
25062530
(this.exports.TVMFFIFunctionCall as ctypes.FTVMFFIFunctionCall)(
2507-
frame.cell.getHandle(),
2531+
(frame.retainedCell ?? frame.cell).getHandle(),
25082532
frame.stack.ptrFromOffset(frame.argsOffset),
25092533
frame.numArgs,
25102534
frame.stack.ptrFromOffset(frame.retOffset)
@@ -2519,7 +2543,11 @@ export class Instance implements Disposable {
25192543
private releasePackedCall(frame: PackedCallFrame): void {
25202544
if (!frame.released) {
25212545
frame.released = true;
2522-
this.lib.recycleCallStack(frame.stack);
2546+
try {
2547+
this.lib.recycleCallStack(frame.stack);
2548+
} finally {
2549+
frame.retainedCell?.dispose();
2550+
}
25232551
}
25242552
}
25252553

‎web/tests/node/test_ffi_bytes.js‎

Lines changed: 71 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -123,16 +123,23 @@ test("PackedFunc captures Wasm-backed byte sources automatically", () => {
123123
}
124124
try {
125125
const input = new Uint8Array(tvm.memory.memory.buffer, ptr, 32);
126-
input.set(makeBytes(input.length));
127-
expect(tvm.captureWasmByteSources([input])).toEqual([{
128-
buffer: input.buffer,
129-
byteOffset: input.byteOffset,
130-
byteLength: input.byteLength,
131-
}]);
132-
tvm.withNewScope(() => {
133-
const echo = tvm.getGlobalFunc("testing.echo");
134-
expectExactBytes(echo(input), makeBytes(input.length));
135-
});
126+
const expected = makeBytes(input.length);
127+
input.set(expected);
128+
const capture = tvm.captureWasmByteSources;
129+
tvm.captureWasmByteSources = function(args) {
130+
const sources = capture.call(this, args);
131+
this.memory.memory.grow(1);
132+
return sources;
133+
};
134+
try {
135+
tvm.withNewScope(() => {
136+
const echo = tvm.getGlobalFunc("testing.echo");
137+
expectExactBytes(echo(input), expected);
138+
});
139+
expect(input.byteLength).toBe(0);
140+
} finally {
141+
tvm.captureWasmByteSources = capture;
142+
}
136143
} finally {
137144
tvm.exports.TVMWasmFreeSpace(ptr);
138145
}
@@ -214,6 +221,60 @@ test("Asyncify rejects overlapping calls from different wrappers", async () => {
214221
}
215222
});
216223

224+
test("Asyncify call retains the function across alias disposal", async () => {
225+
tvm.beginScope();
226+
let release;
227+
try {
228+
const input = makeBytes(64 * 1024 + 17);
229+
const gate = new Promise((resolve) => {
230+
release = resolve;
231+
});
232+
tvm.registerAsyncifyFunc("tvmjs.testing.wait_for_alias_disposal", async () => {
233+
await gate;
234+
}, true);
235+
236+
const callback = tvm.getGlobalFunc("tvmjs.testing.wait_for_alias_disposal");
237+
const original = tvm.getGlobalFunc(
238+
"tvmjs.testing.check_byte_array_across_callbacks"
239+
);
240+
const active = tvm.wrapAsyncifyPackedFunc(original);
241+
const alias = tvm.wrapAsyncifyPackedFunc(original);
242+
243+
const pending = active(input, callback);
244+
alias.dispose();
245+
expect(original._tvmPackedCell.getHandle(false)).toBe(0);
246+
release();
247+
expect(await pending).toBe(0);
248+
} finally {
249+
if (release !== undefined) {
250+
release();
251+
}
252+
tvm.endScope();
253+
}
254+
});
255+
256+
test("Asyncify clears call state when frame cleanup throws", async () => {
257+
tvm.beginScope();
258+
const releasePackedCall = tvm.releasePackedCall;
259+
try {
260+
const original = tvm.getGlobalFunc("testing.echo");
261+
const wrapped = tvm.wrapAsyncifyPackedFunc(original);
262+
tvm.releasePackedCall = function(frame) {
263+
releasePackedCall.call(this, frame);
264+
throw new Error("expected frame cleanup failure");
265+
};
266+
267+
const pending = wrapped(1);
268+
wrapped.dispose();
269+
await expect(pending).rejects.toThrow("expected frame cleanup failure");
270+
expect(original._tvmPackedCell.getHandle(false)).toBe(0);
271+
expect(tvm.asyncifyCallInProgress).toBe(false);
272+
} finally {
273+
tvm.releasePackedCall = releasePackedCall;
274+
tvm.endScope();
275+
}
276+
});
277+
217278
test("Asyncify does not consume an owned byte result before completion", async () => {
218279
if (!tvm.asyncifyEnabled()) {
219280
throw new Error("The test runtime must be built with Asyncify enabled");

0 commit comments

Comments
 (0)