Skip to content

fix(runtime): honor hook body timeoutMs so nested cross-object writes aren't clamped to 250ms (#1867) - #3232

Merged
os-zhuang merged 3 commits into
mainfrom
claude/sandbox-nested-cross-object-writes-nd5n0z
Jul 18, 2026
Merged

fix(runtime): honor hook body timeoutMs so nested cross-object writes aren't clamped to 250ms (#1867)#3232
os-zhuang merged 3 commits into
mainfrom
claude/sandbox-nested-cross-object-writes-nd5n0z

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

背景

#1867(P0):子对象的 afterInsert/afterUpdate 钩子里做嵌套跨对象写入(ctx.api.object('parent').update(...))会让 QuickJS 沙箱崩溃 —— memory access out of bounds。这条限制迫使五个模板把 header/rollup 字段改成手工维护的反规范化写法。

关键发现

崩溃本身在 main 上已经被修复了。 现在的沙箱采用 deferred-promise + pump 的宿主调用模型(host 调用不再走 asyncify,每次调用都跑在各自独立的 newAsyncContext() VM 里),所以 asyncify「同一栈不能二次展开」的限制不再触发。我用真实的 ObjectQL 引擎 + 真实 QuickJSScriptRunner 复现了 issue 的场景(子对象钩子写父对象、且父对象自身也带钩子 → 真正的嵌套 VM),4 层嵌套写入链约 65ms 完成,无崩溃、无超时。issue 复现所用的 9.5.1 早于这次重构。

真正遗留的可靠性瓶颈是超时钳制。resolveTimeout 把引擎默认值(钩子 250ms)直接塞进了 Math.min(...),于是对钩子它永远是最小值:作者按 spec(ScriptBody.timeoutMs 允许最大 30_000ms)声明了更大的 timeoutMs、想给嵌套 rollup 留出时间,却被无声钳回 250ms 并在执行中途被 kill。这是一处「声明了但未强制执行」的缺口(AGENTS.md Prime Directive #10),正是把作者推向反规范化写法的那根稻草。

改动

packages/runtime/src/sandbox/quickjs-runner.tsresolveTimeout:

  • 引擎默认值改为兜底值(仅当调用方未提供任何显式超时时使用),不再是硬上限。
  • 显式的 body.timeoutMs(以及经 opts 传入的外层 hook/action 超时)会被尊重;两者都存在时取较小者 —— 与 spec「smaller of this and the enclosing hook/action timeout wins」一致。
  • 未声明的 body 仍拿到 250ms(钩子)/ 5000ms(action)默认值;body 仍可把自己的超时调低到默认值以下。
- return Math.min(...[def, opts.timeoutMs, bodyTimeoutMs].filter((n): n is number => typeof n === 'number'));+ const explicit = [opts.timeoutMs, bodyTimeoutMs].filter((n): n is number => typeof n === 'number');+ return explicit.length > 0 ? Math.min(...explicit) : def;

测试

  • quickjs-runner.test.ts 新增 6 个用例:
    • 嵌套沙箱重入(单层、4 层链、并发 fan-out)不崩溃且返回正确;
    • 超时解析:尊重 body 声明的、高于默认值的 timeoutMs(600ms 的宿主调用在 5000ms 预算内完成);未声明时仍套用默认;仍可调低。
  • 新增 nested-write.integration.test.ts:真实 ObjectQL 引擎 + 沙箱,子对象 afterInsert/afterUpdate 钩子把总额 rollup 到父对象(父对象也带钩子 → 真正的嵌套 VM),无崩溃,得到正确的反规范化总额。

@objectstack/runtime 全量 564 个测试通过;tsc --noEmiteslint 均通过。附带 changeset(patch)。

关于 ctx.services.data

issue 提到「ctx.services.data 在沙箱里是 undefined(静默 no-op)」。这一项刻意不改:沙箱内数据访问的既定契约是 ctx.api(带 capability 门禁);再暴露一个 ctx.services.data 会形成第二套方言、并绕过 capability 门禁(违反 Prime Directive #5/#12)。既然 ctx.api 的嵌套写入现在可用,这个「替代方案」也就不需要了。沙箱里访问 ctx.services 会明确抛错,而非静默吞掉写入。

影响

嵌套跨对象写入(「子记录变化时更新父记录」)现在既安全又可靠,header/rollup 字段不再需要手工维护的反规范化 workaround。

🤖 Generated with Claude Code


Generated by Claude Code

… aren't clamped to 250ms (#1867)
Nested cross-object writes from a hook (`ctx.api.object('parent').update(...)`
from a child's afterInsert/afterUpdate) once crashed the QuickJS sandbox with
`memory access out of bounds` — the old single-suspended-asyncify host-call
model could not unwind the WASM stack twice. That crash is already fixed on main
by the deferred-promise + pump host-call model (host calls no longer asyncify;
each invocation gets its own isolated VM), so any depth of nested re-entrancy
now composes safely.
The remaining reliability blocker was the timeout: `resolveTimeout` folded the
engine default (250ms for hooks) straight into `Math.min(...)`, so it always
dominated. A hook body that declared a larger `timeoutMs` (the spec allows up to
30_000ms via `ScriptBody.timeoutMs`) to give a legitimate nested-write rollup
room to settle was silently clamped back to 250ms and killed mid-flight — a
declared-but-unenforced knob (AGENTS.md Prime Directive #10) that pushed authors
toward denormalized hand-maintained rollups.
The engine default is now a FALLBACK used only when no explicit timeout is
supplied, not a hard ceiling. An explicit `body.timeoutMs` (and/or an enclosing
hook/action timeout via opts) is honored; when both are present the smaller
wins. Unspecified bodies still get the 250ms hook / 5000ms action default, and a
body may still lower its own timeout below the default.
Tests:
- quickjs-runner.test.ts — nested sandbox re-entrancy (single, 4-level chain,
concurrent fan-out) does not crash; timeout resolution honors a body's
declared timeoutMs above the default, still applies the default when
unspecified, and still lets a body lower it.
- nested-write.integration.test.ts — real ObjectQL engine + sandbox: a child
afterInsert/afterUpdate hook rolls its total up to the parent (parent also has
a hook → real nested VM) without crashing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP
@vercel

vercelBot commented Jul 18, 2026

Copy link
Copy Markdown

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

ProjectDeploymentActionsUpdated (UTC)
specReadyReadyPreview, CommentJul 18, 2026 4:56pm

Request Review

@github-actionsgithub-actionsBot added documentation Improvements or additions to documentation tests tooling size/m labels Jul 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime.

16 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/plugins/packages.mdx(via @objectstack/runtime)
  • 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.

claude added 2 commits July 18, 2026 15:55
#1867)
Add a "Nested cross-object writes" note to the hook-bodies guide: a body may
write other objects (child afterInsert/afterUpdate → parent update), the target's
own hooks fire in a fresh sandbox VM, it composes to any depth, and a deeper or
slower rollup chain should declare a larger timeoutMs (up to 30s) rather than
rely on the 250ms single-write default. Documents the capability the runtime now
delivers instead of the denormalized workaround.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP
…ollup (#1867)
Wire the real ObjectQL engine to the real SqlDriver (better-sqlite3, on-disk)
and drive the expense-template rollup pattern through the hook → sandbox →
nested-write path: an expense_line afterInsert/afterUpdate hook recomputes and
writes expense_report.total_amount. Confirms the child→parent nested write lands
the correct total on a real database with no `memory access out of bounds`
crash — the platform limit the templates' CHARTERs documented as forcing
denormalized workarounds.
Scoped to insert/update (where the hook can resolve the child FK); a comment
notes delete-safe aggregate rollups belong to the native `summary` field, since
an afterDelete hook receives only `{ id, options }` with no pre-image of the
deleted row's FK.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BZguyAaQbyUpwMZ2gMLaAP
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@os-zhuang@claude