From 5796b10c607d724a5af218c3698f4d1b72b12524 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 16:37:13 +0000 Subject: [PATCH] perf(runtime): stop sandbox pump loop idle-spinning while awaiting a host call (#3233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP --- .changeset/sandbox-pump-idle-backoff.md | 20 ++++++++ .../src/sandbox/quickjs-runner.test.ts | 47 +++++++++++++++++++ .../runtime/src/sandbox/quickjs-runner.ts | 23 ++++++++- 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 .changeset/sandbox-pump-idle-backoff.md diff --git a/.changeset/sandbox-pump-idle-backoff.md b/.changeset/sandbox-pump-idle-backoff.md new file mode 100644 index 0000000000..459f597466 --- /dev/null +++ b/.changeset/sandbox-pump-idle-backoff.md @@ -0,0 +1,20 @@ +--- +'@objectstack/runtime': patch +--- + +perf(runtime): stop the sandbox pump loop from idle-spinning while awaiting a host call (#3233) + +The QuickJS hook/action runner drives a script's async continuations with a +pump loop that, on every iteration, yielded via `setImmediate` and then drained +the VM job queue. While the body was only *waiting* on an in-flight host promise +(a slow `ctx.api` read/write, or one call that settles after many event-loop +turns), that queue was empty every iteration, so the loop woke ~200k times/sec +doing nothing — a ~50,000-iteration burn for a 250ms wait. + +The yield is now adaptive: it stays on `setImmediate` (near-zero latency) while +the script is making progress, and once a pump executes zero VM jobs it ramps up +to a small capped `setTimeout` (≤8ms). Any executed job — a settled host call, a +resumed continuation — resets it to the fast path, so sequential host calls and +multi-turn work keep their low latency; only a genuinely idle wait backs off. +Deadline enforcement and every existing pump-budget/timeout/transaction +guarantee are unchanged. diff --git a/packages/runtime/src/sandbox/quickjs-runner.test.ts b/packages/runtime/src/sandbox/quickjs-runner.test.ts index 7e0b25786e..e7f2f14025 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.test.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.test.ts @@ -732,3 +732,50 @@ describe('QuickJSScriptRunner — ctx.api.transaction', () => { expect(events).toEqual([{ op: 'insert', tx: null }]); }, 30000); }); + +// --------------------------------------------------------------------------- +// Idle pump backoff (#3233). While the body only *waits* on an in-flight host +// promise, the pump loop must not spin setImmediate ~200k×/s doing nothing — it +// ramps up to a small capped setTimeout. Correctness (progress + deadline) is +// unchanged; these assert the spin is gone and settlements are still caught. +// --------------------------------------------------------------------------- +describe('QuickJSScriptRunner — idle pump backoff (#3233)', () => { + it('does not busy-spin while idle-waiting on a slow host call — pump count stays bounded', async () => { + // A host call that never settles; the deadline cuts it off. Under the old + // unconditional setImmediate yield this reported ~50k pump iterations for a + // ~250ms wait; the adaptive backoff keeps it to a small bounded number. + const api = { object: () => ({ update: () => new Promise(() => {}) }) }; + const err = await runner + .runScript( + { language: 'js', source: "return await ctx.api.object('x').update({});", capabilities: ['api.write'], timeoutMs: 300 }, + ctx({ api }), + hookOpts, + ) + .then(() => null, (e) => e as SandboxError); + expect(err).toBeInstanceOf(SandboxError); + const m = /after (\d+) pump iterations/.exec(err!.message); + expect(m, `timeout message should report the pump count: ${err?.message}`).toBeTruthy(); + const pumps = Number(m![1]); + // ~300ms at an ≤8ms idle poll ≈ tens of pumps, not tens of thousands. + expect(pumps).toBeLessThan(1000); + }, 10000); + + it('still promptly catches a host call that settles during the idle backoff', async () => { + // Settles at ~120ms — past the fast-pump window, squarely in the backoff + // regime — and must still resolve well within the timeout. + const api = { + object: () => ({ + update: async () => { await new Promise((r) => setTimeout(r, 120)); return { ok: true }; }, + }), + }; + const start = Date.now(); + const r = await runner.runScript( + { language: 'js', source: "return await ctx.api.object('x').update({});", capabilities: ['api.write'], timeoutMs: 5000 }, + ctx({ api }), + hookOpts, + ); + expect(r.value).toEqual({ ok: true }); + // Caught within a backoff slice of the ~120ms settlement, nowhere near the timeout. + expect(Date.now() - start).toBeLessThan(1000); + }, 10000); +}); diff --git a/packages/runtime/src/sandbox/quickjs-runner.ts b/packages/runtime/src/sandbox/quickjs-runner.ts index 3fd29f77fb..80c7473f99 100644 --- a/packages/runtime/src/sandbox/quickjs-runner.ts +++ b/packages/runtime/src/sandbox/quickjs-runner.ts @@ -221,10 +221,26 @@ export class QuickJSScriptRunner implements ScriptRunner { // are parked on a host promise, so this deadline check is the backstop). // The previous fixed `pumps < 1000` cap fired in ~tens of ms on legitimate // work and surfaced as "did not resolve after 1000 pump iterations". + // Adaptive idle backoff (#3233): while the script is progressing we pump + // on setImmediate (near-zero latency); once it is only *waiting* on an + // in-flight host promise — 0 VM jobs executed per pump — we ramp the yield + // up to a small capped setTimeout instead of spinning setImmediate + // ~200k×/s doing nothing. 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, and by at most IDLE_BACKOFF_CAP_MS. + const FAST_PUMPS = 4; + const IDLE_BACKOFF_CAP_MS = 8; let pumps = 0; + let idle = 0; for (;;) { - // Yield to host event loop so any in-flight host promises resolve. - await new Promise((resolve) => setImmediate(resolve)); + // Yield to the host event loop so in-flight host promises can settle. + if (idle < FAST_PUMPS) { + await new Promise((resolve) => setImmediate(resolve)); + } else { + const backoffMs = Math.min(IDLE_BACKOFF_CAP_MS, 1 << Math.min(idle - FAST_PUMPS, 3)); + await new Promise((resolve) => setTimeout(resolve, backoffMs)); + } const pending = runtime.executePendingJobs(); if (pending.error) { @@ -261,6 +277,9 @@ export class QuickJSScriptRunner implements ScriptRunner { `${args.origin.kind} '${args.origin.name}' exceeded timeout of ${args.timeoutMs}ms (after ${pumps} pump iterations)`, ); } + // Progress this pump → reset to the fast path; genuinely idle → ramp the + // counter so the next yield backs off. + idle = pending.value > 0 ? 0 : idle + 1; pumps++; } } finally {