Skip to content

fix(runtime): sandbox budget = script CPU-time, not wall clock (ADR-0102 D1, #3295)#3301

Merged
os-zhuang merged 2 commits into
mainfrom
claude/nested-write-sandbox-timeout-muw13i
Jul 19, 2026
Merged

fix(runtime): sandbox budget = script CPU-time, not wall clock (ADR-0102 D1, #3295)#3301
os-zhuang merged 2 commits into
mainfrom
claude/nested-write-sandbox-timeout-muw13i

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Implements Phase 1 of #3275 (ADR-0102 D1). Closes #3295.

What & why

The QuickJS sandbox now meters each hook/action invocation against VM-active (CPU) time, not wall clock. Idle host-await time and a nested hook's own execution (which runs host-side while the caller's VM is parked) are no longer charged to the caller — so a slow/loaded host or a deep nested-write chain can't trip the budget while a script is merely waiting. That was the root cause of the #3259 flake, and it also fixes the "nested hooks billed to the parent" problem. A separate, generous wall-clock ceiling (default 30s, max(ceiling, cpuBudget)) backstops a body stuck on a host call that never settles.

Changes

  • runner (quickjs-runner.ts): slice-stopwatch every VM entry (evalCode / evalCodeAsync / each executePendingJobs). The interrupt handler and the post-slice checks compare cpuMs against the CPU budget and Date.now() against the wall deadline. Distinct errors: exceeded CPU budget of Nms vs exceeded wall-clock ceiling of Nms while awaiting host calls. A mid-slice interrupt — which for an async body surfaces via __error, not pending.error — is mapped to the clean budget message at all three error sites.
  • new knobs (additive): QuickJSScriptRunner option wallCeilingMs + env OS_SANDBOX_WALL_CEILING_MS; resolveSandboxTimeoutMs (@objectstack/types) gains a 'wallCeiling' kind. Precedence unchanged: explicit option › env › built-in default.
  • latent-bug fix: initialise sliceStart to start, not 0. A 0 init made now - sliceStart the full unix-epoch millis, firing the interrupt during installCtx and corrupting ctx marshalling (__input never set) — this would have mis-fired in production, not just tests.
  • tests: never-settling-host "timeout" tests → CPU-burn bodies asserting /CPU budget of Nms/; genuine stuck-host cases → dedicated small-wallCeilingMs runners asserting /wall-clock ceiling/; added the Flaky: nested-write.integration.test.ts sandbox hook exceeds the 250ms deadline on CI (Test Core) #3259 root-cause guard (idle host time resolves) and CPU-vs-ceiling coverage; resolveSandboxTimeoutMs wall-ceiling unit test.
  • nested-write*.integration: dropped the explicit 10s budget — they now pass at the stock 250ms, which is the regression guard for the nested-charging fix.
  • docs: environment-variables.mdx CPU-time wording + OS_SANDBOX_WALL_CEILING_MS; ADR-0102 D1 → Accepted. Changeset carries the FROM→TO semantics note.

Behaviour change (see changeset)

Knob names, defaults (250ms/5000ms), and precedence are unchanged — only the dimension (wall → CPU) and the error strings change (exceeded timeout of NmsCPU budget / wall-clock ceiling). In practice this only loosens legitimate slow/nested work; a runaway synchronous script is still cut at the same budget.

Verification

  • @objectstack/runtime full suite — 574 passed (incl. the sandbox suite in ~5s with no hangs, and all production-runner consumers: app-plugin, dispatcher).
  • @objectstack/types26 passed.
  • eslint, check:doc-authoring, and @objectstack/types + @objectstack/runtime builds (tsup DTS typecheck) all green.

Follow-ups (separate PRs, per ADR)

🤖 Generated with Claude Code


Generated by Claude Code

…102 D1, #3295)

Phase 1 of #3275. The QuickJS sandbox now meters each hook/action invocation
against VM-active (CPU) time, not wall clock. Idle host-await time and a nested
hook's own execution (host-side, while the caller VM is parked) are no longer
charged to the caller — so a slow/loaded host or a deep nested-write chain can't
trip the budget while a script is merely waiting (the #3259 flake root cause). A
separate wall-clock ceiling (default 30s, max(ceiling, cpuBudget)) backstops a
body stuck on a never-settling host call.

- runner: slice-stopwatch every VM entry (evalCode / evalCodeAsync / each
  executePendingJobs); the interrupt handler and post-slice checks compare
  against the CPU budget and the wall deadline; distinct errors "exceeded CPU
  budget of Nms" vs "exceeded wall-clock ceiling of Nms while awaiting host
  calls". A mid-slice interrupt — which for an async body surfaces via __error,
  not pending.error — is mapped to the clean budget message at all three sites.
- new knobs: QuickJSScriptRunner wallCeilingMs + env OS_SANDBOX_WALL_CEILING_MS
  (resolveSandboxTimeoutMs gains a 'wallCeiling' kind). Precedence unchanged:
  explicit option > env > built-in default.
- fix: init sliceStart to `start`, not 0 — a 0 init made `now - sliceStart` the
  full epoch millis, firing the interrupt during installCtx and corrupting ctx
  marshalling (__input never set).
- tests: rewrite never-settling-host timeout tests to CPU-burn bodies (assert
  /CPU budget of Nms/) + dedicated small-wallCeilingMs runners for the genuine
  stuck-host cases (assert /wall-clock ceiling/); add the #3259 root-cause guard
  (idle host time resolves) and CPU-vs-ceiling coverage.
- nested-write*.integration: drop the explicit 10s budget — they pass at the
  stock 250ms now, the nested-charging-fix regression guard.
- docs: environment-variables.mdx CPU-time wording + OS_SANDBOX_WALL_CEILING_MS;
  ADR-0102 D1 → Accepted. Changeset carries the FROM→TO semantics note.

574 runtime tests + 26 types tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iJLjqToxNv1aYP3syQtRp
@vercel

vercel Bot commented Jul 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Canceled Canceled Jul 19, 2026 4:59pm

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 19, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/runtime, @objectstack/types.

17 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/api/index.mdx (via @objectstack/runtime)
  • content/docs/api/wire-format.mdx (via @objectstack/runtime)
  • content/docs/automation/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/runtime)
  • content/docs/deployment/index.mdx (via @objectstack/runtime)
  • content/docs/deployment/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/deployment/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/deployment/vercel.mdx (via @objectstack/runtime)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/runtime)
  • content/docs/permissions/authentication.mdx (via @objectstack/runtime)
  • content/docs/permissions/authorization.mdx (via packages/runtime)
  • content/docs/plugins/packages.mdx (via @objectstack/runtime, @objectstack/types)
  • content/docs/protocol/kernel/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/runtime)
  • content/docs/releases/implementation-status.mdx (via @objectstack/runtime)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

… no larger timeout (ADR-0102)

hook-bodies.mdx said per-invocation "timeouts" are wall-clock and told authors
to raise `timeoutMs` for deeper/slower rollup chains. Under ADR-0102 D1 the
budget is script CPU-time: awaiting host calls and nested-hook execution are not
charged, so the stock 250ms default covers deep rollups and the guidance to bump
`timeoutMs` no longer applies. Notes the 30s wall ceiling + OS_SANDBOX_* knobs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iJLjqToxNv1aYP3syQtRp
@os-zhuang os-zhuang added the allow-major label Jul 19, 2026 — with Claude
@os-zhuang
os-zhuang marked this pull request as ready for review July 19, 2026 16:59
@os-zhuang
os-zhuang merged commit 92f5f19 into main Jul 19, 2026
18 of 19 checks passed
@os-zhuang
os-zhuang deleted the claude/nested-write-sandbox-timeout-muw13i branch July 19, 2026 16:59
os-zhuang added a commit that referenced this pull request Jul 19, 2026
…ation (#3297) (#3318)

Phase 3 of #3275. Records ADR-0102 D3 as deferred: verifying quickjs-emscripten 0.32 shows no clean, portable, edge-safe way to obtain the sync variant's .wasm bytes to precompile (Node fs/fetch breaks Workers/edge-safety; replicating locateFile is fragile), and the payoff is uncertain (engines already code-cache wasm compilation). Phase 2 (#3309) already delivered the dominant win. D1 (#3301) + D2 (#3309) stand on their own; revisit criterion recorded. allow-major label bypasses the unrelated pre-existing console:major changeset on main. Closes #3297.
os-zhuang pushed a commit that referenced this pull request Jul 20, 2026
… ADR-0102 D1

#3270 raised the sandbox hook budget workflow-wide to paper over the #3259 load
flake: on an oversubscribed runner the fixed per-invocation WASM-creation cost
alone tripped the 250ms *wall-clock* hook deadline. ADR-0102 D1 (#3301) changed
the budget to *script CPU-time* — VM creation and idle host-await time are no
longer charged — so the 250ms default is meaningful again on a loaded runner and
this global floor is no longer needed. Removing it also validates the fix
end-to-end: the sandbox suite (incl. the nested-write integration tests, which
run at the stock default) must stay green at 250ms on CI. Tests that assert a
specific budget set it explicitly and are unaffected; production always kept
250ms/5000ms. The env override remains available for constrained hardware.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011iJLjqToxNv1aYP3syQtRp
os-zhuang added a commit that referenced this pull request Jul 20, 2026
… ADR-0102 D1 (#3328)

#3270 raised the sandbox hook budget workflow-wide to paper over the #3259 load
flake: on an oversubscribed runner the fixed per-invocation WASM-creation cost
alone tripped the 250ms *wall-clock* hook deadline. ADR-0102 D1 (#3301) changed
the budget to *script CPU-time* — VM creation and idle host-await time are no
longer charged — so the 250ms default is meaningful again on a loaded runner and
this global floor is no longer needed. Removing it also validates the fix
end-to-end: the sandbox suite (incl. the nested-write integration tests, which
run at the stock default) must stay green at 250ms on CI. Tests that assert a
specific budget set it explicitly and are unaffected; production always kept
250ms/5000ms. The env override remains available for constrained hardware.


Claude-Session: https://claude.ai/code/session_011iJLjqToxNv1aYP3syQtRp

Co-authored-by: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

allow-major documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

sandbox Phase 1 — charge script CPU time, not wall clock

2 participants