Skip to content

Commit 83fd318

Browse files
authored
fix(runtime): sandbox host calls via deferred promises, deadline-bounded pump (#1972)
1 parent 44c5348 commit 83fd318

4 files changed

Lines changed: 179 additions & 22 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
fix(runtime): drive sandbox host calls with deferred promises and a deadline-bounded pump
6+
7+
The QuickJS sandbox exposed `ctx.api.object(x).find/update/...` via `newAsyncifiedFunction`, which unwinds the WASM stack per host call and forbids a second call while the first is unwound. A script awaiting two host calls in sequence (e.g. an action doing `findOne()` then `update()`) drove the second call from a continuation resumed inside `executePendingJobs`, corrupting the wasm heap (`memory access out of bounds` / `p->ref_count == 0`) or exhausting the fixed 1000-iteration pump budget — surfacing as `action '…' did not resolve after 1000 pump iterations`.
8+
9+
Host API methods are now exposed as deferred QuickJS promises (`vm.newPromise()`), so sequential `await`s compose with no stack unwinding, and the pump loop is bounded by the configured `timeoutMs` instead of a fixed iteration cap. Host **method** calls now require `await` (the `.object(name)` proxy getter stays synchronous); a stuck/never-settling host call is cut off with a clear timeout error.

packages/runtime/src/sandbox/body-runner.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ describe('hookBodyRunnerFactory', () => {
8181
events: ['afterInsert'],
8282
body: {
8383
language: 'js',
84-
source: 'return { opportunity_count: ctx.api.object("opportunity").count() };',
84+
source: 'return { opportunity_count: await ctx.api.object("opportunity").count() };',
8585
capabilities: ['api.read'],
8686
},
8787
} as any);

packages/runtime/src/sandbox/quickjs-runner.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,3 +212,103 @@ describe('QuickJSScriptRunner — async host APIs', () => {
212212
expect(r.mutatedInput).toMatchObject({ raw: 'abc-9', normalized: 'ABC-9' });
213213
});
214214
});
215+
216+
describe('QuickJSScriptRunner — long-running async host work (pump budget)', () => {
217+
// Regression: an action's single `ctx.api.update(...)` can synchronously drive
218+
// a large amount of awaited host work — e.g. a record-change flow that the
219+
// engine runs inline inside the afterUpdate hook chain (see tianshun-mtc
220+
// `lead_apply_convert` → `lead_convert_approval`). From the sandbox's view
221+
// that is ONE asyncified host call that takes many event-loop turns to settle.
222+
//
223+
// The pump loop must bound that wait by the configured `timeoutMs`, NOT by a
224+
// fixed iteration count: a legitimately-progressing call that needs >1000
225+
// event-loop turns but finishes well within the timeout must still resolve.
226+
// The old fixed `pumps < 1000` cap fired in ~tens of ms and surfaced as
227+
// "did not resolve after 1000 pump iterations" — the exact production error.
228+
229+
it('resolves an action whose host call settles after >1000 event-loop turns', async () => {
230+
const TURNS = 1500; // comfortably exceeds the old 1000-pump cap
231+
let observed = 0;
232+
const api = {
233+
object: () => ({
234+
// One asyncified host call that internally needs many macrotask turns
235+
// before its promise settles — mirrors a CRUD write that synchronously
236+
// runs a long downstream automation.
237+
update: async () => {
238+
for (let i = 0; i < TURNS; i++) {
239+
await new Promise<void>((resolve) => setImmediate(resolve));
240+
}
241+
observed = TURNS;
242+
return { ok: true };
243+
},
244+
}),
245+
};
246+
247+
const r = await runner.runScript(
248+
{
249+
language: 'js',
250+
source: "return await ctx.api.object('wid').update({ id: 'x', status: 'pending' });",
251+
capabilities: ['api.write'],
252+
timeoutMs: 30000,
253+
},
254+
ctx({ api }),
255+
actionOpts,
256+
);
257+
258+
expect(r.value).toEqual({ ok: true });
259+
expect(observed).toBe(TURNS);
260+
}, 40000);
261+
262+
it('resolves an action that makes >1000 sequential host calls', async () => {
263+
const N = 1500;
264+
let calls = 0;
265+
const api = {
266+
object: () => ({
267+
update: async () => {
268+
calls++;
269+
return { i: calls };
270+
},
271+
}),
272+
};
273+
274+
const r = await runner.runScript(
275+
{
276+
language: 'js',
277+
source: `
278+
const o = ctx.api.object('wid');
279+
for (let i = 0; i < ${N}; i++) { await o.update({ id: 'x', i }); }
280+
return { calls: ${N} };
281+
`,
282+
capabilities: ['api.write'],
283+
timeoutMs: 30000,
284+
},
285+
ctx({ api }),
286+
actionOpts,
287+
);
288+
289+
expect(r.value).toEqual({ calls: N });
290+
expect(calls).toBe(N);
291+
}, 40000);
292+
293+
it('still enforces the timeout on a host call that never settles', async () => {
294+
const api = {
295+
object: () => ({
296+
// Never resolves — must be killed by the deadline, not hang forever.
297+
update: () => new Promise<never>(() => {}),
298+
}),
299+
};
300+
301+
await expect(
302+
runner.runScript(
303+
{
304+
language: 'js',
305+
source: "return await ctx.api.object('wid').update({ id: 'x' });",
306+
capabilities: ['api.write'],
307+
timeoutMs: 300,
308+
},
309+
ctx({ api }),
310+
actionOpts,
311+
),
312+
).rejects.toThrow(/timeout/i);
313+
}, 10000);
314+
});

packages/runtime/src/sandbox/quickjs-runner.ts

Lines changed: 69 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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
*/
329352
function 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

Comments
 (0)