fix(code): Workers 實部署修正 —— wasmfile+Module 載入 + tick-budget timeout(CF 實測)

leo21c 實部署發現兩個 CF 限制並修正:
1. CF 禁 runtime 從 bytes 編譯 wasm(WebAssembly.instantiate(bytes) 被 embedder 擋)
   → singlefile(base64) 內嵌不可用。改 wasmfile variant + `import wasm from './vendor/quickjs.wasm'`
   (wrangler CompiledWasm rule 綁成預編 WebAssembly.Module),newVariant({wasmModule}) 注入。
   sandbox 改 variant 注入制(setVariant):Worker 注 wrangler Module、Node 由 bytes 建 Module,
   同一 production 路徑受測。vendor/quickjs.wasm 由 postinstall 自 node_modules 複製(gitignored)。
2. CF 凍結同步執行期 Date.now → wall-clock deadline 對純同步迴圈失效(撞 CF CPU 回 1102)。
   改指令計數 interrupt(max_ticks,CF-safe),wall-clock 留 Node 保護。校準:cadence≈5000
   指令/tick、真實 card 解析≈4 ticks、CF 門檻≈1000+ ticks → 預設 max_ticks=500。
   有 body 的迴圈(含 100M 迴圈)皆乾淨回 TimeoutError;空體 while(true){} 仍由 CF CPU guard 容納。

Worker live: arcrun-code.leo21c.workers.dev(cypher-executor 以此 workers.dev 慣例位址呼叫)。
Node 單測 12/12 綠(wasmfile variant + 注入 Module,同 production 路徑)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
This commit is contained in:
Leo
2026-07-06 05:15:50 +00:00
parent 1a7b4639c3
commit 08a79229a5
6 changed files with 1057 additions and 70 deletions
+14 -3
View File
@@ -5,8 +5,11 @@
* → QuickJS-wasm 沙箱(./sandbox.mjs 的 runCode
* → { success:true, data } | { success:false, error, error_type }
*
* 封裝=Aquickjs-emscripten singlefile variantwasm 內嵌為 base64、同步載入,
* bundler 友善、無需 [[wasm_modules]] 綁定、無需 nodejs_compatsandbox 用 TextEncoder 計 bytes
* 封裝=Aquickjs-emscripten wasmfile variant。關鍵:CF Workers 禁止 runtime 從 bytes
* 編譯 wasmWebAssembly.instantiate(bytes) 被 embedder 擋),故不能用 singlefile(base64) 內嵌
* 改為 `import wasm from '.../wasm'` 讓 wrangler 在 build 時把 .wasm 綁成一個「已編好的
* WebAssembly.Module」,再以 newVariant({ wasmModule }) 注入沙箱 → 執行期只 instantiate 既有
* Module、不編譯,合 Workers 規則。無需 nodejs_compatsandbox 用 TextEncoder 計 bytes)。
*
* 與其他 logic 零件不同:`code` 是自足 Worker(自帶 index.ts + sandbox.mjs + quickjs variant),
* 不走 component-worker-template 的 TinyGo-wasm bundling 流程。部署見 DEPLOY.md。
@@ -14,8 +17,16 @@
import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { newVariant } from 'quickjs-emscripten-core';
import baseVariant from '@jitl/quickjs-wasmfile-release-sync';
// wrangler 把相對路徑 .wasm import 綁成 WebAssembly.Modulebuild 時編好,執行期不重編)。
// wasm 從 quickjs-wasmfile-release-sync vendored 進 vendor/(見 DEPLOY.md「vendor 步驟」)。
import wasmModule from './vendor/quickjs.wasm';
// @ts-expect-error —— sandbox.mjs 為 runtime-agnostic JS 核心(Node 測試與 Worker 共用同一份)
import { runCode } from './sandbox.mjs';
import { runCode, setVariant } from './sandbox.mjs';
// 注入預編 Module(模組載入時一次)。之後 runCode 只 instantiate、不編譯。
setVariant(newVariant(baseVariant, { wasmModule: wasmModule as WebAssembly.Module }));
const app = new Hono();
app.use('*', cors());
File diff suppressed because it is too large Load Diff
+7 -3
View File
@@ -6,12 +6,16 @@
"description": "arcrun code 零件 —— sandbox inline JSQuickJS-wasm, Workers-ready",
"main": "index.ts",
"scripts": {
"test": "vitest run --config vitest.config.mjs"
"postinstall": "node scripts/vendor-wasm.mjs",
"vendor": "node scripts/vendor-wasm.mjs",
"test": "vitest run --config vitest.config.mjs",
"predeploy": "node scripts/vendor-wasm.mjs",
"deploy": "wrangler deploy"
},
"dependencies": {
"@jitl/quickjs-wasmfile-release-sync": "^0.32.0",
"hono": "^4.7.0",
"quickjs-emscripten-core": "^0.31.0",
"@jitl/quickjs-singlefile-mjs-release-sync": "^0.32.0"
"quickjs-emscripten-core": "^0.31.0"
},
"devDependencies": {
"vitest": "^3.1.0",
+26 -7
View File
@@ -10,16 +10,30 @@
// 要給的能力,只能由 host 明確、逐一注入 —— 目前唯一 curated builtin = 純函式 `sha256`
// 且以「純 JS 演算法字串 prelude」注入(不呼叫 host、不用 async Web CryptoNode/Worker 皆決定性)。
//
// 封裝方式:quickjs-emscripten「singlefile」variantwasm 內嵌為 base64、同步載入),
// 這是 Cloudflare Workers 相容的 loading 路徑(不靠 fetch/fs 取 .wasm
// 封裝方式:quickjs-emscripten「wasmfile」variant + 預編 WebAssembly.Module 注入(見下方 setVariant)。
// CF Workers 禁 runtime 從 bytes 編譯 wasm,故不用 singlefile(base64);改由 build 時編好 Module
//
// 對齊 arcrun 契約:io_model=stdin_stdout_json、no_network_syscall、no_filesystem_syscall。
import variant from '@jitl/quickjs-singlefile-mjs-release-sync';
// wasm 載入以「variant 注入」制:CF Workers 禁止 runtime 從 bytes 編譯 wasm
// WebAssembly.instantiate(bytes) 被 embedder 擋),故必須用「已編好的 WebAssembly.Module」。
// - Workerindex.ts):import 的 .wasm 由 wrangler 綁成 WebAssembly.Module → newVariant 注入。
// - Node/vitest:由 .wasm bytes 建 new WebAssembly.Module(...) → 同一 newVariant 路徑注入。
// 呼叫方必須在 runCode 前 setVariant()。sandbox 本身不綁定任何 variant(不 bundle 錯的 loader)。
import { newQuickJSWASMModuleFromVariant } from 'quickjs-emscripten-core';
let _variant = null;
/** 注入 quickjs variant(已含預編 WebAssembly.Module)。Worker 與 Node 各自注入自己的。 */
export function setVariant(v) { _variant = v; _modulePromise = null; }
export const DEFAULT_LIMITS = {
timeout_ms: 1000, // 執行牆鐘上限(interrupt handler 逐指令檢查 deadline
timeout_ms: 1000, // 牆鐘上限(Node/本機保護;CF 同步執行會凍結 Date.now,故非主保護
max_ticks: 500, // ★ 指令計數上限(CF 主保護):interrupt 回呼被叫超過此數即中止。
// CF Workers 凍結同步 Date.now → 純同步無窮迴圈只能靠此計數中止。
// 校準(leo21c 實測):interrupt cadence ≈ 5000 指令/tick
// 真實 card 解析 ≈ 4 ticksCF CPU 1102 門檻 ≈ 1000+ ticks。
// 500 ticks(≈ 2.5M 指令)= card 的 125× 餘裕、且穩在 CF 門檻下。
// 需更多算力的 user code 可提高 limits.max_ticks(但別逼近 ~1000)。
memory_bytes: 16 * 1024 * 1024, // QuickJS runtime 記憶體硬上限
max_stack_bytes: 512 * 1024, // 遞迴/深堆疊上限
max_output_bytes: 1024 * 1024, // stdout JSON 大小上限(防跑飛)
@@ -103,7 +117,8 @@ function sha256(ascii) {
let _modulePromise = null;
function getModule() {
if (!_modulePromise) _modulePromise = newQuickJSWASMModuleFromVariant(variant);
if (!_variant) throw new Error('sandbox variant not set — 呼叫 setVariant() 注入預編 WebAssembly.Module');
if (!_modulePromise) _modulePromise = newQuickJSWASMModuleFromVariant(_variant);
return _modulePromise;
}
@@ -129,7 +144,11 @@ export async function runCode(code, input, opts = {}) {
const deadline = Date.now() + limits.timeout_ms;
let interrupted = false;
let ticks = 0;
runtime.setInterruptHandler(() => {
// 主保護(CF-safe):指令計數。CF 凍結同步 Date.now,純同步無窮迴圈只能靠此中止。
if (++ticks > limits.max_ticks) { interrupted = true; return true; }
// 次保護(Node/本機):牆鐘 deadline(CF 同步期間不會推進,故僅在有 I/O 或非 CF 生效)。
if (Date.now() > deadline) { interrupted = true; return true; }
return false;
});
@@ -154,7 +173,7 @@ export async function runCode(code, input, opts = {}) {
if (evalResult.error) {
const detail = ctx.dump(evalResult.error);
evalResult.error.dispose();
if (interrupted) return err(`execution timed out after ${limits.timeout_ms}ms`, 'TimeoutError');
if (interrupted) return err(`execution aborted: exceeded time (${limits.timeout_ms}ms) or instruction budget (${limits.max_ticks} ticks)`, 'TimeoutError');
const msg = typeof detail === 'object' && detail
? `${detail.name || 'Error'}: ${detail.message || ''}`.trim()
: String(detail);
@@ -169,7 +188,7 @@ export async function runCode(code, input, opts = {}) {
}
return { success: true, data: JSON.parse(outJson) };
} catch (e) {
if (interrupted) return err(`execution timed out after ${limits.timeout_ms}ms`, 'TimeoutError');
if (interrupted) return err(`execution aborted: exceeded time (${limits.timeout_ms}ms) or instruction budget (${limits.max_ticks} ticks)`, 'TimeoutError');
const m = e instanceof Error ? e.message : String(e);
if (/out of memory|memory/i.test(m)) return err('out of memory', 'ResourceError');
return err(m, 'SandboxError');
@@ -0,0 +1,10 @@
// 把 quickjs-wasmfile variant 的 .wasm 複製進 vendor/,供 index.ts 以相對路徑 import
// (wrangler 只可靠處理相對路徑 .wasm → CompiledWasmnode_modules 子路徑 .wasm 解析不穩)。
// 由 postinstall 自動執行;vendor/*.wasm 為 build 產物、gitignored。
import { copyFileSync, mkdirSync } from 'node:fs';
import { createRequire } from 'node:module';
const require = createRequire(import.meta.url);
const src = require.resolve('@jitl/quickjs-wasmfile-release-sync/wasm');
mkdirSync(new URL('../vendor/', import.meta.url), { recursive: true });
copyFileSync(src, new URL('../vendor/quickjs.wasm', import.meta.url));
console.log('vendored quickjs.wasm from', src);
@@ -2,12 +2,19 @@ import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { runCode } from '../sandbox.mjs';
import { runCode, setVariant } from '../sandbox.mjs';
import { planCard } from './fixtures/card-to-envelope.oracle.mjs';
import baseVariant from '@jitl/quickjs-wasmfile-release-sync';
import { newVariant } from 'quickjs-emscripten-core';
const HERE = dirname(fileURLToPath(import.meta.url));
const FIX = join(HERE, 'fixtures');
// 注入與 productionWorker)同一條路徑:wasmfile variant + 預編 WebAssembly.Module。
// Worker 由 wrangler 把 import 的 .wasm 綁成 Module;此處在 Node 由 bytes 建 Module。
const wasmBytes = readFileSync(join(HERE, '..', 'node_modules', '@jitl', 'quickjs-wasmfile-release-sync', 'dist', 'emscripten-module.wasm'));
setVariant(newVariant(baseVariant, { wasmModule: new WebAssembly.Module(wasmBytes) }));
describe('① 基本 snippet 跑通', () => {
it('sum: {return {sum: input.a + input.b}}', async () => {
const r = await runCode('return {sum: input.a + input.b};', { a: 2, b: 40 });