Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/sandbox-pump-idle-backoff.md
Original file line number Diff line number Diff line change
@@ -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.
47 changes: 47 additions & 0 deletions packages/runtime/src/sandbox/quickjs-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<never>(() => {}) }) };
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<void>((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);
});
23 changes: 21 additions & 2 deletions packages/runtime/src/sandbox/quickjs-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve) => setImmediate(resolve));
// Yield to the host event loop so in-flight host promises can settle.
if (idle < FAST_PUMPS) {
await new Promise<void>((resolve) => setImmediate(resolve));
} else {
const backoffMs = Math.min(IDLE_BACKOFF_CAP_MS, 1 << Math.min(idle - FAST_PUMPS, 3));
await new Promise<void>((resolve) => setTimeout(resolve, backoffMs));
}

const pending = runtime.executePendingJobs();
if (pending.error) {
Expand Down Expand Up @@ -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 {
Expand Down