Skip to content

Commit 32899e6

Browse files
authored
fix(runtime): env-overridable sandbox hook/action timeout to de-flake CI (#3259) (#3270)
Make the QuickJS sandbox per-invocation hook/action timeout default env-overridable (OS_SANDBOX_HOOK_TIMEOUT_MS / OS_SANDBOX_ACTION_TIMEOUT_MS) so a loaded/slow host can raise the floor once, deployment-wide; set 10000ms in CI to de-flake Test Core. Precedence unchanged (explicit option > env > built-in default; body timeoutMs still wins). Corrects the stale "we pool runtimes" docstring. Closes #3259.
1 parent 3ca5320 commit 32899e6

7 files changed

Lines changed: 207 additions & 9 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
---
2+
'@objectstack/runtime': minor
3+
'@objectstack/types': minor
4+
---
5+
6+
feat(runtime): env-overridable sandbox hook/action timeout default (#3259)
7+
8+
The QuickJS sandbox enforces a wall-clock deadline on every hook/action
9+
invocation (250ms hooks / 5000ms actions). Each invocation compiles a fresh
10+
WASM module, and a nested hook compiles ANOTHER one inside the parent's budget,
11+
so on a heavily loaded or slow host — an oversubscribed CI runner, constrained
12+
production hardware — that fixed VM-creation cost alone can trip the hook
13+
default even while the VM is still making progress. On CI this surfaced as an
14+
intermittent `hook '…' exceeded timeout of 250ms` flake on PRs that never
15+
touched the sandbox path.
16+
17+
The per-invocation timeout DEFAULT is now resolvable from the environment via
18+
`resolveSandboxTimeoutMs` (`@objectstack/types`), which `QuickJSScriptRunner`
19+
consults, so an operator can raise the floor once, deployment-wide, instead of
20+
re-tuning every call site:
21+
22+
- `OS_SANDBOX_HOOK_TIMEOUT_MS` — default hook budget (ms)
23+
- `OS_SANDBOX_ACTION_TIMEOUT_MS` — default action budget (ms)
24+
25+
Precedence is unchanged: an explicit `hookTimeoutMs` / `actionTimeoutMs` passed
26+
to the runner still wins over the env var, and a body's own declared `timeoutMs`
27+
still wins over the resolved default (the smaller of the explicit values). Only
28+
a positive integer is honored; unset / empty / non-numeric / non-positive keeps
29+
the built-in 250ms / 5000ms defaults, so behaviour is byte-for-byte unchanged
30+
when the vars are absent — production is unaffected unless it opts in.
31+
32+
CI's Test Core now sets `OS_SANDBOX_HOOK_TIMEOUT_MS=10000` so the shared-runner
33+
load flake can't recur; genuine hangs stay bounded by each test's own timeout.

.github/workflows/ci.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@ concurrency:
1515
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
1616
cancel-in-progress: true
1717

18+
# The QuickJS sandbox compiles a fresh WASM module per hook/action invocation
19+
# (and a nested hook compiles another inside the parent's budget). Hosted runners
20+
# are 4-vCPU and the test job oversubscribes them (`turbo --concurrency=4` + each
21+
# vitest worker), so that fixed VM-creation cost alone intermittently tripped the
22+
# 250ms hook default even while the VM was progressing — a load flake unrelated to
23+
# the change under test (#3259). Raise the sandbox hook budget for the whole
24+
# workflow; production keeps the 250ms default (this only sets it in CI). Real
25+
# hangs are still bounded by each test's own vitest timeout.
26+
env:
27+
OS_SANDBOX_HOOK_TIMEOUT_MS: '10000'
28+
1829
jobs:
1930
filter:
2031
runs-on: ubuntu-latest

content/docs/deployment/environment-variables.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -331,6 +331,8 @@ the hosted ObjectOS Cloud control plane.
331331
| `OS_ENV_CACHE_TTL_MS` | number | `300000` | Env-record cache TTL in ms. |
332332
| `OS_ARTIFACT_CACHE_TTL_MS` | number | `300000` | Artifact-record cache TTL in ms. |
333333
| `OS_ARTIFACT_FETCH_TIMEOUT_MS` | number | `60000` | Timeout for remote artifact fetches. Only a positive value is honored — an unset, non-numeric, or `0` value falls back to the 60s default (pass `fetchTimeoutMs: 0` in `artifactSource` config to actually disable the timeout). |
334+
| `OS_SANDBOX_HOOK_TIMEOUT_MS` | number | `250` | Default wall-clock budget for a sandboxed **hook** body (QuickJS). Each invocation compiles a fresh WASM module and a nested hook compiles another inside the parent's budget, so a loaded/slow host (e.g. an oversubscribed CI runner) may need a larger floor. Only a positive integer is honored; unset / non-numeric / non-positive keeps the 250ms default. A hook body's own declared `timeoutMs` still wins over this. |
335+
| `OS_SANDBOX_ACTION_TIMEOUT_MS` | number | `5000` | Default wall-clock budget for a sandboxed **action** body (QuickJS). Same resolution rules as the hook variant above (positive integer only; an action body's own `timeoutMs` still wins). |
334336
| `OS_INLINE_SEED_BUDGET_MS` | number | `8000` | Time budget for synchronous seed execution at boot before deferring to a worker. |
335337
| `OS_TENANT_AUDIT` | flag | `1` | Set to `0` to silence the tenant-isolation audit warnings emitted by the SQL driver. |
336338

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

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { describe, it, expect } from 'vitest';
3+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
44
import { QuickJSScriptRunner, SandboxError } from './quickjs-runner.js';
55
import type { ScriptContext, ScriptRunOptions } from './script-runner.js';
66

@@ -370,9 +370,13 @@ describe('QuickJSScriptRunner — nested sandbox re-entrancy (#1867)', () => {
370370
// the 250ms hook default and killed mid-flight.
371371
// ---------------------------------------------------------------------------
372372
describe('QuickJSScriptRunner — timeout resolution honors body.timeoutMs (#1867)', () => {
373-
// Stock engine defaults (250ms hooks) — these tests assert the default budget
374-
// itself, so they must NOT use the generous shared `runner` above.
375-
const defaultRunner = new QuickJSScriptRunner();
373+
// Pin the hook default to 250ms EXPLICITLY (not the bare stock constructor) so
374+
// these assertions on the effective budget stay independent of any ambient
375+
// `OS_SANDBOX_HOOK_TIMEOUT_MS` the environment/CI may set (#3259). They assert
376+
// the resolution logic — body.timeoutMs vs the runner default — which is
377+
// identical whether that default came from the built-in constant or an
378+
// explicit option. (A dedicated suite below covers the env override itself.)
379+
const defaultRunner = new QuickJSScriptRunner({ hookTimeoutMs: 250 });
376380

377381
it('honors a hook body timeoutMs above the 250ms hook default', async () => {
378382
// Host call settles at ~600ms — comfortably past the old 250ms hook cap but
@@ -423,6 +427,60 @@ describe('QuickJSScriptRunner — timeout resolution honors body.timeoutMs (#186
423427
}, 10000);
424428
});
425429

430+
// ---------------------------------------------------------------------------
431+
// Env-overridable timeout defaults (#3259). Every invocation compiles a fresh
432+
// WASM module, and a nested hook compiles another inside the parent's budget, so
433+
// on a loaded/slow host (an oversubscribed CI runner) the 250ms hook default can
434+
// trip even while the VM is making progress. `OS_SANDBOX_HOOK_TIMEOUT_MS` lets an
435+
// operator raise the FALLBACK default without a code change; an explicit
436+
// constructor option still wins over the env, and a body's own timeoutMs still
437+
// wins over the resolved default. Each test constructs its runner AFTER setting
438+
// the env (the constructor reads it once), and the env is saved/restored so a
439+
// CI-wide value doesn't leak in or out.
440+
// ---------------------------------------------------------------------------
441+
describe('QuickJSScriptRunner — env-overridable timeout defaults (#3259)', () => {
442+
const HOOK_ENV = 'OS_SANDBOX_HOOK_TIMEOUT_MS';
443+
let saved: string | undefined;
444+
beforeEach(() => {
445+
saved = process.env[HOOK_ENV];
446+
delete process.env[HOOK_ENV];
447+
});
448+
afterEach(() => {
449+
if (saved === undefined) delete process.env[HOOK_ENV];
450+
else process.env[HOOK_ENV] = saved;
451+
});
452+
453+
// A never-settling host call forces the deadline path; the SandboxError embeds
454+
// the RESOLVED budget, so asserting on the message proves which timeout applied
455+
// without a flaky wall-clock measurement.
456+
const neverSettles = { object: () => ({ update: () => new Promise<never>(() => {}) }) };
457+
const runHook = (r: QuickJSScriptRunner) =>
458+
r.runScript(
459+
{ language: 'js', source: "return await ctx.api.object('x').update({});", capabilities: ['api.write'] },
460+
ctx({ api: neverSettles }),
461+
hookOpts,
462+
);
463+
464+
it('falls back to the built-in 250ms hook default when the env var is unset', async () => {
465+
await expect(runHook(new QuickJSScriptRunner())).rejects.toThrow(/timeout of 250ms/);
466+
}, 10000);
467+
468+
it('uses OS_SANDBOX_HOOK_TIMEOUT_MS as the default when set', async () => {
469+
process.env[HOOK_ENV] = '150';
470+
await expect(runHook(new QuickJSScriptRunner())).rejects.toThrow(/timeout of 150ms/);
471+
}, 10000);
472+
473+
it('lets an explicit constructor option win over the env var', async () => {
474+
process.env[HOOK_ENV] = '150';
475+
await expect(runHook(new QuickJSScriptRunner({ hookTimeoutMs: 50 }))).rejects.toThrow(/timeout of 50ms/);
476+
}, 10000);
477+
478+
it('ignores a non-numeric / non-positive env value and keeps the built-in default', async () => {
479+
process.env[HOOK_ENV] = 'not-a-number';
480+
await expect(runHook(new QuickJSScriptRunner())).rejects.toThrow(/timeout of 250ms/);
481+
}, 10000);
482+
});
483+
426484
describe('QuickJSScriptRunner — long-running async host work (pump budget)', () => {
427485
// Regression: an action's single `ctx.api.update(...)` can synchronously drive
428486
// a large amount of awaited host work — e.g. a record-change flow that the

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

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,16 @@
1717
* `ctx.api.object('foo').count(...)` and the host method runs in node).
1818
*
1919
* Trade-offs:
20-
* - Per-invocation overhead is dominated by VM creation. We pool runtimes per
21-
* `(origin.kind, capabilities-set)` to amortise startup. Pool size is bounded
22-
* by `maxPooled` (default 8); evicted runtimes are disposed.
20+
* - Per-invocation overhead is dominated by VM creation: every call compiles a
21+
* fresh WASM module via `newAsyncContext()` and disposes it on settle (see
22+
* {@link QuickJSScriptRunner.execute}). Runtimes are deliberately NOT pooled —
23+
* a shared module hit HostRef double-free crashes when contexts were disposed
24+
* concurrently, and the per-invocation isolate sidesteps that. The cost is
25+
* real, though: a nested hook compiles a SECOND module inside the parent's
26+
* budget, so on a loaded/slow host the fixed compile cost alone can trip the
27+
* hook timeout. The per-invocation timeout default is therefore env-overridable
28+
* via `OS_SANDBOX_HOOK_TIMEOUT_MS` / `OS_SANDBOX_ACTION_TIMEOUT_MS`
29+
* (framework#3259) so an operator can raise the floor without a code change.
2330
* - Memory caps are advisory under quickjs (engine has no hard MB cap); the
2431
* runner uses `setMemoryLimit(memoryMb * 1MB)` which is best-effort.
2532
*/
@@ -29,6 +36,7 @@ import {
2936
type QuickJSAsyncContext,
3037
type QuickJSHandle,
3138
} from 'quickjs-emscripten';
39+
import { resolveSandboxTimeoutMs } from '@objectstack/types';
3240
import type { HookBody, ScriptBody, ExpressionBody, HookBodyCapability } from '@objectstack/spec/data';
3341
import type {
3442
ScriptContext,
@@ -55,9 +63,15 @@ export class QuickJSScriptRunner implements ScriptRunner {
5563
private opts: Required<QuickJSScriptRunnerOptions>;
5664

5765
constructor(opts: QuickJSScriptRunnerOptions = {}) {
66+
// Precedence for the per-invocation timeout default: an explicit constructor
67+
// option wins; else the deployment's `OS_SANDBOX_{HOOK,ACTION}_TIMEOUT_MS`
68+
// env override (so a loaded/slow host — e.g. an oversubscribed CI runner —
69+
// can raise the floor without a code change, framework#3259); else the
70+
// built-in default. `resolveSandboxTimeoutMs` returns the built-in fallback
71+
// untouched when the env var is unset, so default behaviour is unchanged.
5872
this.opts = {
59-
hookTimeoutMs: opts.hookTimeoutMs ?? DEFAULT_HOOK_TIMEOUT_MS,
60-
actionTimeoutMs: opts.actionTimeoutMs ?? DEFAULT_ACTION_TIMEOUT_MS,
73+
hookTimeoutMs: opts.hookTimeoutMs ?? resolveSandboxTimeoutMs('hook', DEFAULT_HOOK_TIMEOUT_MS),
74+
actionTimeoutMs: opts.actionTimeoutMs ?? resolveSandboxTimeoutMs('action', DEFAULT_ACTION_TIMEOUT_MS),
6175
memoryMb: opts.memoryMb ?? DEFAULT_MEMORY_MB,
6276
};
6377
}

packages/types/src/env.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
readEnvWithDeprecation,
77
resolveAllowDegradedTenancy,
88
resolveSearchPinyinEnabled,
9+
resolveSandboxTimeoutMs,
910
isMcpServerEnabled,
1011
resolveMcpStdioAutoStart,
1112
} from './env.js';
@@ -237,3 +238,49 @@ describe('MCP switches — HTTP surface vs stdio auto-start are decoupled (#3167
237238
expect(resolveMcpStdioAutoStart()).toEqual({ enabled: true, viaDeprecatedAlias: false });
238239
});
239240
});
241+
242+
describe('resolveSandboxTimeoutMs (#3259)', () => {
243+
const HOOK = 'OS_SANDBOX_HOOK_TIMEOUT_MS';
244+
const ACTION = 'OS_SANDBOX_ACTION_TIMEOUT_MS';
245+
const origHook = process.env[HOOK];
246+
const origAction = process.env[ACTION];
247+
afterEach(() => {
248+
if (origHook === undefined) delete process.env[HOOK];
249+
else process.env[HOOK] = origHook;
250+
if (origAction === undefined) delete process.env[ACTION];
251+
else process.env[ACTION] = origAction;
252+
});
253+
254+
it('returns the fallback unchanged when the var is unset', () => {
255+
delete process.env[HOOK];
256+
delete process.env[ACTION];
257+
expect(resolveSandboxTimeoutMs('hook', 250)).toBe(250);
258+
expect(resolveSandboxTimeoutMs('action', 5000)).toBe(5000);
259+
});
260+
261+
it('reads the kind-specific var and parses a positive integer', () => {
262+
process.env[HOOK] = '10000';
263+
process.env[ACTION] = '20000';
264+
expect(resolveSandboxTimeoutMs('hook', 250)).toBe(10000);
265+
expect(resolveSandboxTimeoutMs('action', 5000)).toBe(20000);
266+
});
267+
268+
it('does not cross the wires between the hook and action vars', () => {
269+
process.env[HOOK] = '999';
270+
delete process.env[ACTION];
271+
expect(resolveSandboxTimeoutMs('hook', 250)).toBe(999);
272+
expect(resolveSandboxTimeoutMs('action', 5000)).toBe(5000); // action unset → fallback
273+
});
274+
275+
it('ignores empty / non-numeric / non-positive values and keeps the fallback', () => {
276+
for (const bad of ['', ' ', 'abc', '0', '-5', 'NaN']) {
277+
process.env[HOOK] = bad;
278+
expect(resolveSandboxTimeoutMs('hook', 250), `value=${JSON.stringify(bad)}`).toBe(250);
279+
}
280+
});
281+
282+
it('tolerates a leading integer with trailing junk (parseInt semantics, as resolveOrgLimit)', () => {
283+
process.env[HOOK] = '3000ms';
284+
expect(resolveSandboxTimeoutMs('hook', 250)).toBe(3000);
285+
});
286+
});

packages/types/src/env.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,39 @@ export function resolveSearchPinyinEnabled(opts?: { locales?: readonly string[]
239239
return (opts?.locales ?? []).some((l) => /^zh([-_]|$)/i.test(String(l ?? '').trim()));
240240
}
241241

242+
/**
243+
* SINGLE decision point for the sandbox script-runner's DEFAULT per-invocation
244+
* timeout (ms), for a given origin kind, from the environment (framework#3259).
245+
*
246+
* The QuickJS sandbox enforces a wall-clock deadline on every hook/action
247+
* invocation. The built-in defaults (250ms hooks / 5000ms actions) suit a warm,
248+
* idle host — but every invocation compiles a fresh WASM module, and a nested
249+
* hook compiles ANOTHER one inside the parent's budget, so on a heavily loaded
250+
* or slow host (an oversubscribed CI runner, constrained production hardware)
251+
* that fixed VM-creation cost alone can trip the hook default even while the VM
252+
* is still making progress. That surfaced as an intermittent CI flake ("hook
253+
* '…' exceeded timeout of 250ms"). This lets an operator raise the floor once,
254+
* deployment-wide, instead of re-tuning every call site.
255+
*
256+
* Canonical vars (OS_{DOMAIN}_{NAME}, DOMAIN=SANDBOX):
257+
* - hook → `OS_SANDBOX_HOOK_TIMEOUT_MS`
258+
* - action → `OS_SANDBOX_ACTION_TIMEOUT_MS`
259+
*
260+
* Only a positive integer is honored; unset / empty / non-numeric / non-positive
261+
* falls back to `fallback`, so behaviour is byte-for-byte unchanged when the var
262+
* is absent. This is a FALLBACK default ONLY: an explicit `hookTimeoutMs` /
263+
* `actionTimeoutMs` passed to the runner constructor still wins over it, and a
264+
* body's own declared `timeoutMs` still wins over the resolved default per the
265+
* runner's timeout-resolution rule (the smaller of the explicit values).
266+
*/
267+
export function resolveSandboxTimeoutMs(kind: 'hook' | 'action', fallback: number): number {
268+
const name = kind === 'hook' ? 'OS_SANDBOX_HOOK_TIMEOUT_MS' : 'OS_SANDBOX_ACTION_TIMEOUT_MS';
269+
const raw = readEnvWithDeprecation(name, [], { silent: true });
270+
if (raw == null || String(raw).trim() === '') return fallback;
271+
const n = Number.parseInt(String(raw).trim(), 10);
272+
return Number.isFinite(n) && n > 0 ? n : fallback;
273+
}
274+
242275
/**
243276
* Internal: clear the dedupe set. Test-only; exposed so suite-wide
244277
* deprecation warnings don't bleed between tests.

0 commit comments

Comments
 (0)