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

vercelBot commented Jul 19, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specCanceledCanceledJul 19, 2026 4:59pm

Request Review

@github-actionsgithub-actionsBot 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 marked this pull request as ready for review July 19, 2026 16:59
@os-zhuang
os-zhuang merged commit 92f5f19 into mainJul 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 freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

allow-majordocumentationImprovements or additions to documentationsize/mteststooling

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

@os-zhuang@claude