Skip to content

fix(runtime): sandbox host calls via deferred promises, deadline-bounded pump#1972

Merged
os-zhuang merged 2 commits into
mainfrom
fix/sandbox-write-triggers-flow-reentrancy
Jun 16, 2026
Merged

fix(runtime): sandbox host calls via deferred promises, deadline-bounded pump#1972
os-zhuang merged 2 commits into
mainfrom
fix/sandbox-write-triggers-flow-reentrancy

Conversation

@baozhoutao

Copy link
Copy Markdown
Contributor

Problem

Production error from POST /api/v1/actions/mtc_lead/lead_apply_convert:

action 'lead_apply_convert' did not resolve after 1000 pump iterations

Root cause

The QuickJS sandbox exposed ctx.api.object(x).find/update/... via newAsyncifiedFunction. Asyncify unwinds the WASM stack for each host call and forbids a second call while the first is unwound ("the stack cannot be unwound twice").

A script that awaits two host calls in sequence — the real lead_apply_convert action does findOne() then update() — hits exactly that: the second call is driven from a resumed continuation inside executePendingJobs (a non-async frame). That corrupts the wasm heap (memory access out of bounds / Assertion failed: p->ref_count == 0, the偶发 crash already noted in tianshun-mtc's test log) and, when it limps along instead, exhausts the fixed 1000-iteration pump budget — the production error above.

I verified empirically that asyncify cannot drive 2+ host calls per invocation under any driver (manual executePendingJobs pump or module top-level await) — both crash with "cannot handle error in suspended function".

Fix

  1. Host API methods → deferred QuickJS promises (vm.newPromise()) instead of asyncified functions. Sequential awaits become ordinary promises with no stack unwinding, so any number of host calls compose safely. Host method calls now require await (every real action already does; the .object(name) proxy getter stays synchronous).
  2. Pump loop bound by timeoutMs instead of a magic 1000-iteration cap. A slow-but-progressing script (many sequential writes, or one write that synchronously drives a downstream record-change automation) finishes within its timeout; a stuck / never-settling host call is cut off with a clear timeout error (the interrupt handler can't fire while parked on a host promise, so the deadline check is the backstop).

Tests

New regression tests in quickjs-runner.test.ts:

  • a host call settling after >1000 event-loop turns (one long write),
  • >1000 sequential host calls (previously crashed the wasm),
  • a never-settling host call that must time out (previously hung).

body-runner.test.ts updated: its ctx.api.object("opportunity").count() now uses await (it previously relied on asyncify's magic-sync, the exact fragile behavior removed).

All 31 sandbox tests pass. The one unrelated publish-drafts seed rows failure in this package is pre-existing (fails identically on main without these changes).

…ded pump

The QuickJS sandbox exposed `ctx.api.object(x).find/update/...` via
`newAsyncifiedFunction`. Asyncify unwinds the WASM stack for each host call and
forbids a second call while the first is unwound ("the stack cannot be unwound
twice"). A script that awaits two host calls in sequence — e.g. the real
`lead_apply_convert` action doing `findOne()` then `update()` — hit exactly
that: the second call was driven from a resumed continuation inside
`executePendingJobs` (a non-async frame), which corrupted the wasm heap
(`memory access out of bounds` / `Assertion failed: p->ref_count == 0`) and,
when it limped along, exhausted the fixed 1000-iteration pump budget, surfacing
in production as:

  action 'lead_apply_convert' did not resolve after 1000 pump iterations

Verified empirically that asyncify cannot drive 2+ host calls per invocation
under any driver (manual pump or module top-level await) — both crash.

Fix:
- Expose host API methods as deferred QuickJS promises (`vm.newPromise()`)
  instead of asyncified functions. Sequential `await`s become ordinary promises
  with no stack unwinding, so any number of host calls compose safely. (Host
  method calls now require `await`, which all real actions already do; the
  `.object(name)` proxy getter stays synchronous.)
- Bound the pump loop by the configured `timeoutMs` deadline instead of a magic
  1000-iteration cap, so a slow-but-progressing script (many sequential writes,
  or one write that synchronously drives a downstream record-change automation)
  finishes within its timeout, while a stuck/never-settling host call is cut off
  with a clear timeout error (the interrupt handler can't fire while parked on a
  host promise, so this deadline check is the backstop).

Regression tests (quickjs-runner.test.ts): a host call settling after >1000
event-loop turns, >1000 sequential host calls, and a never-settling host call
that must time out. Before the fix the sequential case crashed the wasm and the
never-settling case hung; both now pass.
@vercel

vercel Bot commented Jun 16, 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 Jun 16, 2026 3:57pm

Request Review

@github-actions

github-actions Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

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

  • content/docs/concepts/cloud-artifact-api.mdx (via packages/runtime)
  • content/docs/concepts/implementation-status.mdx (via @objectstack/runtime)
  • content/docs/concepts/north-star.mdx (via packages/runtime)
  • content/docs/concepts/packages.mdx (via @objectstack/runtime)
  • content/docs/guides/api-reference.mdx (via @objectstack/runtime)
  • content/docs/guides/authentication.mdx (via @objectstack/runtime)
  • content/docs/guides/cloud-deployment.mdx (via @objectstack/runtime)
  • content/docs/guides/deployment-vercel.mdx (via @objectstack/runtime)
  • content/docs/guides/driver-configuration.mdx (via @objectstack/runtime)
  • content/docs/guides/hook-bodies.mdx (via @objectstack/runtime)
  • content/docs/guides/packages.mdx (via @objectstack/runtime)
  • content/docs/guides/plugin-chatbot-integration.mdx (via @objectstack/runtime)
  • content/docs/guides/production-readiness.mdx (via @objectstack/runtime)
  • content/docs/guides/single-project-mode.mdx (via @objectstack/runtime)
  • content/docs/protocol/objectos/http-protocol.mdx (via @objectstack/runtime)
  • content/docs/protocol/objectos/index.mdx (via @objectstack/runtime)
  • content/docs/protocol/objectos/lifecycle.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.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tooling labels Jun 16, 2026
@os-zhuang
os-zhuang merged commit 83fd318 into main Jun 16, 2026
14 checks passed
@os-zhuang
os-zhuang deleted the fix/sandbox-write-triggers-flow-reentrancy branch June 16, 2026 22:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants