Skip to content

Commit 3a6310c

Browse files
os-zhuangclaude
andauthored
perf(runtime): stop sandbox pump loop idle-spinning while awaiting a host call (#3242)
The hook/action runner's pump loop yielded via setImmediate every iteration and drained the VM job queue. While the body only *waits* on an in-flight host promise the queue is empty each pass, so the loop woke ~200k×/s doing nothing (~50k iterations for a 250ms wait — surfaced in the timeout message's pump count). Make the yield adaptive: stay on setImmediate while the script is progressing; once a pump executes zero VM jobs, ramp up to a small capped setTimeout (≤8ms). Any executed job (a settled host call, a resumed continuation) resets the fast path, so sequential host calls and multi-turn work keep their low latency — only a genuinely idle wait backs off, by at most the cap. Deadline enforcement and every existing pump-budget/timeout/transaction guarantee are unchanged. Tests: a never-settling host call now reports a bounded pump count (<1000 for a 300ms wait, vs ~50k before); a call that settles at ~120ms — squarely in the backoff regime — still resolves promptly. Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP Co-authored-by: Claude <noreply@anthropic.com>
1 parent dd9f223 commit 3a6310c

3 files changed

Lines changed: 88 additions & 2 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
perf(runtime): stop the sandbox pump loop from idle-spinning while awaiting a host call (#3233)
6+
7+
The QuickJS hook/action runner drives a script's async continuations with a
8+
pump loop that, on every iteration, yielded via `setImmediate` and then drained
9+
the VM job queue. While the body was only *waiting* on an in-flight host promise
10+
(a slow `ctx.api` read/write, or one call that settles after many event-loop
11+
turns), that queue was empty every iteration, so the loop woke ~200k times/sec
12+
doing nothing — a ~50,000-iteration burn for a 250ms wait.
13+
14+
The yield is now adaptive: it stays on `setImmediate` (near-zero latency) while
15+
the script is making progress, and once a pump executes zero VM jobs it ramps up
16+
to a small capped `setTimeout` (≤8ms). Any executed job — a settled host call, a
17+
resumed continuation — resets it to the fast path, so sequential host calls and
18+
multi-turn work keep their low latency; only a genuinely idle wait backs off.
19+
Deadline enforcement and every existing pump-budget/timeout/transaction
20+
guarantee are unchanged.

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

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,3 +741,50 @@ describe('QuickJSScriptRunner — ctx.api.transaction', () => {
741741
expect(events).toEqual([{ op: 'insert', tx: null }]);
742742
}, 30000);
743743
});
744+
745+
// ---------------------------------------------------------------------------
746+
// Idle pump backoff (#3233). While the body only *waits* on an in-flight host
747+
// promise, the pump loop must not spin setImmediate ~200k×/s doing nothing — it
748+
// ramps up to a small capped setTimeout. Correctness (progress + deadline) is
749+
// unchanged; these assert the spin is gone and settlements are still caught.
750+
// ---------------------------------------------------------------------------
751+
describe('QuickJSScriptRunner — idle pump backoff (#3233)', () => {
752+
it('does not busy-spin while idle-waiting on a slow host call — pump count stays bounded', async () => {
753+
// A host call that never settles; the deadline cuts it off. Under the old
754+
// unconditional setImmediate yield this reported ~50k pump iterations for a
755+
// ~250ms wait; the adaptive backoff keeps it to a small bounded number.
756+
const api = { object: () => ({ update: () => new Promise<never>(() => {}) }) };
757+
const err = await runner
758+
.runScript(
759+
{ language: 'js', source: "return await ctx.api.object('x').update({});", capabilities: ['api.write'], timeoutMs: 300 },
760+
ctx({ api }),
761+
hookOpts,
762+
)
763+
.then(() => null, (e) => e as SandboxError);
764+
expect(err).toBeInstanceOf(SandboxError);
765+
const m = /after (\d+) pump iterations/.exec(err!.message);
766+
expect(m, `timeout message should report the pump count: ${err?.message}`).toBeTruthy();
767+
const pumps = Number(m![1]);
768+
// ~300ms at an ≤8ms idle poll ≈ tens of pumps, not tens of thousands.
769+
expect(pumps).toBeLessThan(1000);
770+
}, 10000);
771+
772+
it('still promptly catches a host call that settles during the idle backoff', async () => {
773+
// Settles at ~120ms — past the fast-pump window, squarely in the backoff
774+
// regime — and must still resolve well within the timeout.
775+
const api = {
776+
object: () => ({
777+
update: async () => { await new Promise<void>((r) => setTimeout(r, 120)); return { ok: true }; },
778+
}),
779+
};
780+
const start = Date.now();
781+
const r = await runner.runScript(
782+
{ language: 'js', source: "return await ctx.api.object('x').update({});", capabilities: ['api.write'], timeoutMs: 5000 },
783+
ctx({ api }),
784+
hookOpts,
785+
);
786+
expect(r.value).toEqual({ ok: true });
787+
// Caught within a backoff slice of the ~120ms settlement, nowhere near the timeout.
788+
expect(Date.now() - start).toBeLessThan(1000);
789+
}, 10000);
790+
});

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,10 +221,26 @@ export class QuickJSScriptRunner implements ScriptRunner {
221221
// are parked on a host promise, so this deadline check is the backstop).
222222
// The previous fixed `pumps < 1000` cap fired in ~tens of ms on legitimate
223223
// work and surfaced as "did not resolve after 1000 pump iterations".
224+
// Adaptive idle backoff (#3233): while the script is progressing we pump
225+
// on setImmediate (near-zero latency); once it is only *waiting* on an
226+
// in-flight host promise — 0 VM jobs executed per pump — we ramp the yield
227+
// up to a small capped setTimeout instead of spinning setImmediate
228+
// ~200k×/s doing nothing. Any executed job (a settled host call, a resumed
229+
// continuation) resets the fast path, so sequential host calls and
230+
// multi-turn work keep their low latency; only a genuinely idle wait backs
231+
// off, and by at most IDLE_BACKOFF_CAP_MS.
232+
const FAST_PUMPS = 4;
233+
const IDLE_BACKOFF_CAP_MS = 8;
224234
let pumps = 0;
235+
let idle = 0;
225236
for (;;) {
226-
// Yield to host event loop so any in-flight host promises resolve.
227-
await new Promise<void>((resolve) => setImmediate(resolve));
237+
// Yield to the host event loop so in-flight host promises can settle.
238+
if (idle < FAST_PUMPS) {
239+
await new Promise<void>((resolve) => setImmediate(resolve));
240+
} else {
241+
const backoffMs = Math.min(IDLE_BACKOFF_CAP_MS, 1 << Math.min(idle - FAST_PUMPS, 3));
242+
await new Promise<void>((resolve) => setTimeout(resolve, backoffMs));
243+
}
228244

229245
const pending = runtime.executePendingJobs();
230246
if (pending.error) {
@@ -261,6 +277,9 @@ export class QuickJSScriptRunner implements ScriptRunner {
261277
`${args.origin.kind} '${args.origin.name}' exceeded timeout of ${args.timeoutMs}ms (after ${pumps} pump iterations)`,
262278
);
263279
}
280+
// Progress this pump → reset to the fast path; genuinely idle → ramp the
281+
// counter so the next yield backs off.
282+
idle = pending.value > 0 ? 0 : idle + 1;
264283
pumps++;
265284
}
266285
} finally {

0 commit comments

Comments
 (0)