@@ -156,7 +156,7 @@ export class QuickJSScriptRunner implements ScriptRunner {
156156
157157 // L2 scripts: wrap as async IIFE and use side-channel + asyncified pump.
158158 // Each pump iteration:
159- // 1. yield to the host event loop (lets asyncified host calls resolve )
159+ // 1. yield to the host event loop (lets host promises settle )
160160 // 2. drain QuickJS pending jobs (advances the .then chain)
161161 // 3. read __result/__error from the VM
162162 const wrapped = args . origin . kind === 'hook'
@@ -179,9 +179,20 @@ export class QuickJSScriptRunner implements ScriptRunner {
179179 }
180180 evalRes . value . dispose ( ) ;
181181
182+ // Drive the script's async continuations to completion. Each iteration
183+ // yields to the host event loop (so in-flight host promises settle and
184+ // resolve their VM-side deferred handles) and then drains the QuickJS job
185+ // queue. The ONLY bound on how long we wait is the deadline: a slow but
186+ // progressing script — many sequential host writes, or one write that
187+ // synchronously drives a downstream record-change automation — must be
188+ // allowed to finish within its timeout, and a stuck / never-settling host
189+ // call is cut off here (the QuickJS interrupt handler can't fire while we
190+ // are parked on a host promise, so this deadline check is the backstop).
191+ // The previous fixed `pumps < 1000` cap fired in ~tens of ms on legitimate
192+ // work and surfaced as "did not resolve after 1000 pump iterations".
182193 let pumps = 0 ;
183- while ( pumps < 1000 ) {
184- // Yield to host event loop so any in-flight asyncified host promises resolve.
194+ for ( ; ; ) {
195+ // Yield to host event loop so any in-flight host promises resolve.
185196 await new Promise < void > ( ( resolve ) => setImmediate ( resolve ) ) ;
186197
187198 const pending = runtime . executePendingJobs ( ) ;
@@ -210,14 +221,11 @@ export class QuickJSScriptRunner implements ScriptRunner {
210221
211222 if ( Date . now ( ) > deadline ) {
212223 throw new SandboxError (
213- `${ args . origin . kind } '${ args . origin . name } ' exceeded timeout of ${ args . timeoutMs } ms` ,
224+ `${ args . origin . kind } '${ args . origin . name } ' exceeded timeout of ${ args . timeoutMs } ms (after ${ pumps } pump iterations) ` ,
214225 ) ;
215226 }
216227 pumps ++ ;
217228 }
218- throw new SandboxError (
219- `${ args . origin . kind } '${ args . origin . name } ' did not resolve after ${ pumps } pump iterations` ,
220- ) ;
221229 } finally {
222230 // newAsyncContext() owns its WASM module; disposing the context disposes
223231 // the runtime + module together.
@@ -230,8 +238,9 @@ export class QuickJSScriptRunner implements ScriptRunner {
230238 * the body declared it; missing methods throw at call-time inside the VM
231239 * with a clear diagnostic.
232240 *
233- * Host API methods are installed via {@link QuickJSAsyncContext.newAsyncifiedFunction}
234- * so they may return Promises (real ObjectQL `find/count/insert/...` are async).
241+ * Host API methods are installed as deferred-promise functions (see
242+ * {@link installApiMethod}) so they may return Promises (real ObjectQL
243+ * `find/count/insert/...` are async) without asyncify's single-unwind limit.
235244 */
236245 private installCtx (
237246 vm : QuickJSAsyncContext ,
@@ -320,11 +329,25 @@ export class QuickJSScriptRunner implements ScriptRunner {
320329}
321330
322331/**
323- * Asyncified host-bound API method.
332+ * Host-bound API method, exposed to the VM as an async function.
333+ *
334+ * IMPORTANT: this deliberately does NOT use `newAsyncifiedFunction`. Asyncify
335+ * unwinds the WASM stack while a host call is in flight, and the engine forbids
336+ * one asyncified call from running while another is unwound ("the stack cannot
337+ * be unwound twice"). A script that awaits two host calls in sequence — e.g. the
338+ * real `lead_apply_convert` action doing `findOne()` then `update()` — trips
339+ * exactly that: the second call is driven from a resumed continuation inside
340+ * `executePendingJobs` (a non-async frame), which corrupted the wasm heap
341+ * (`memory access out of bounds` / `p->ref_count == 0`) and, when it limped
342+ * along, blew the pump budget ("did not resolve after 1000 pump iterations").
324343 *
325- * Awaits Promise return values from the host implementation and marshals the
326- * resolved value back into the VM. Capability check happens at call time and
327- * surfaces inside the VM as a thrown error with a clear diagnostic.
344+ * Instead we hand the VM a real QuickJS promise (a deferred) and settle it from
345+ * the host event loop. Sequential `await`s are then ordinary promises with no
346+ * stack unwinding, so any number of host calls compose safely; the pump loop in
347+ * {@link QuickJSScriptRunner.execute} drains the resulting jobs.
348+ *
349+ * The capability check runs synchronously at call time and surfaces inside the
350+ * VM as a thrown error with a clear diagnostic.
328351 */
329352function installApiMethod (
330353 vm : QuickJSAsyncContext ,
@@ -336,7 +359,9 @@ function installApiMethod(
336359 required : HookBodyCapability ,
337360 origin : ScriptOrigin ,
338361) : void {
339- const fn = vm . newAsyncifiedFunction ( method , async ( ...argHandles ) => {
362+ const fn = vm . newFunction ( method , ( ...argHandles ) => {
363+ // Capability gate — throw synchronously so the VM sees a normal exception at
364+ // the call site (mirrors ctx.log / ctx.crypto gating).
340365 if ( ! caps . has ( required ) ) {
341366 throw new SandboxError (
342367 `capability '${ required } ' not granted to ${ origin . kind } '${ origin . name } ' (called ctx.api.object('${ objectName } ').${ method } )` ,
@@ -346,14 +371,37 @@ function installApiMethod(
346371 if ( ! apiAny || typeof apiAny . object !== 'function' ) {
347372 throw new SandboxError ( `ctx.api unavailable in ${ origin . kind } '${ origin . name } '` ) ;
348373 }
374+ // Dump args now, while the handles are alive — they are freed when this
375+ // function returns, long before the async work below runs.
349376 const args = argHandles . map ( ( h ) => vm . dump ( h ) ) ;
350- const proxy = ( apiAny . object as ( n : string ) => Record < string , unknown > ) ( objectName ) ;
351- const m = proxy [ method ] as ( ( ...a : unknown [ ] ) => unknown ) | undefined ;
352- if ( typeof m !== 'function' ) {
353- throw new SandboxError ( `ctx.api.object('${ objectName } ').${ method } not implemented` ) ;
354- }
355- const ret = await Promise . resolve ( m . apply ( proxy , args ) ) ;
356- return jsonToHandle ( vm , ret ) ;
377+
378+ const deferred = vm . newPromise ( ) ;
379+ void ( async ( ) => {
380+ try {
381+ const proxy = ( apiAny . object as ( n : string ) => Record < string , unknown > ) ( objectName ) ;
382+ const m = proxy [ method ] as ( ( ...a : unknown [ ] ) => unknown ) | undefined ;
383+ if ( typeof m !== 'function' ) {
384+ throw new SandboxError ( `ctx.api.object('${ objectName } ').${ method } not implemented` ) ;
385+ }
386+ const ret = await Promise . resolve ( m . apply ( proxy , args ) ) ;
387+ if ( ! vm . alive ) return ; // VM disposed (e.g. timed out) before we settled.
388+ const h = jsonToHandle ( vm , ret ) ;
389+ deferred . resolve ( h ) ;
390+ h . dispose ( ) ;
391+ } catch ( err ) {
392+ if ( ! vm . alive ) return ;
393+ const errH =
394+ err instanceof Error
395+ ? vm . newError ( { name : err . name || 'Error' , message : err . message } )
396+ : vm . newError ( { name : 'Error' , message : String ( err ) } ) ;
397+ deferred . reject ( errH ) ;
398+ errH . dispose ( ) ;
399+ }
400+ } ) ( ) ;
401+ // The pump loop is the sole driver of executePendingJobs, so the resolution
402+ // propagates into the VM on a subsequent pump iteration — no nudge here, to
403+ // avoid any re-entrant executePendingJobs.
404+ return deferred . handle ;
357405 } ) ;
358406 vm . setProp ( parent , method , fn ) ;
359407 fn . dispose ( ) ;
0 commit comments