Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e9bd09072 | |||
| a24f2912eb | |||
| 1791ffa497 | |||
| 296fb01247 | |||
| f1370e2275 |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -2650,7 +2650,7 @@ var init_recipes = __esm({
|
||||
});
|
||||
|
||||
// cypher-executor/src/lib/constants.ts
|
||||
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, BUILTIN_COMPONENTS;
|
||||
var VALID_EDGE_TYPES, SEMANTIC_EDGE_MAP, WAIT_MAX_MS, BUILTIN_COMPONENTS;
|
||||
var init_constants3 = __esm({
|
||||
"cypher-executor/src/lib/constants.ts"() {
|
||||
"use strict";
|
||||
@@ -2698,6 +2698,7 @@ var init_constants3 = __esm({
|
||||
"CLICK": "ON_CLICK",
|
||||
"SUBFLOW": "CALLS_SUBFLOW"
|
||||
};
|
||||
WAIT_MAX_MS = 3e4;
|
||||
BUILTIN_COMPONENTS = /* @__PURE__ */ new Map([
|
||||
["comp_passthrough", (ctx) => ctx],
|
||||
["comp_uppercase", (ctx) => {
|
||||
@@ -2707,6 +2708,54 @@ var init_constants3 = __esm({
|
||||
["comp_counter", (ctx) => {
|
||||
const c = ctx;
|
||||
return { ...c, count: (Number(c.count) || 0) + 1 };
|
||||
}],
|
||||
// ── wait:等待 N 毫秒後繼續(Arcrun#101,2026-08-12)────────────────────────
|
||||
//
|
||||
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
|
||||
//
|
||||
// 舊實作是 registry/components/wait/main.go(TinyGo → WASM),用 time.Sleep。
|
||||
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
|
||||
// poll_oneoff 實作成 ENOSYS(`.component-builds/*/src/index.ts`:`poll_oneoff: () => 76`)
|
||||
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
|
||||
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
|
||||
//
|
||||
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
|
||||
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
|
||||
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
|
||||
// ⇒ 時鐘並沒有全程凍結。
|
||||
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
|
||||
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
|
||||
//
|
||||
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
|
||||
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
|
||||
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
|
||||
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
|
||||
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
|
||||
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
|
||||
//
|
||||
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
|
||||
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
|
||||
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
|
||||
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
|
||||
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
|
||||
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
|
||||
//
|
||||
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
|
||||
// 吃 ms(必填 > 0)+可選 context;ms > WAIT_MAX_MS 截斷;
|
||||
// 回 { success: true, data: { ...context, waited_ms } };ms <= 0 回 success:false。
|
||||
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
|
||||
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
|
||||
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
|
||||
["wait", async (ctx) => {
|
||||
const c = ctx && typeof ctx === "object" ? ctx : {};
|
||||
const requested = typeof c.ms === "number" ? c.ms : Number(c.ms);
|
||||
if (!Number.isFinite(requested) || requested <= 0) {
|
||||
return { success: false, error: "ms \u5FC5\u9808\u5927\u65BC 0" };
|
||||
}
|
||||
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const passthrough = c.context && typeof c.context === "object" && !Array.isArray(c.context) ? c.context : {};
|
||||
return { success: true, data: { ...passthrough, waited_ms: ms } };
|
||||
}]
|
||||
]);
|
||||
}
|
||||
@@ -3060,7 +3109,12 @@ var init_component_loader = __esm({
|
||||
filter: "SVC_FILTER",
|
||||
merge: "SVC_MERGE",
|
||||
try_catch: "SVC_TRY_CATCH",
|
||||
wait: "SVC_WAIT",
|
||||
// wait 已於 Arcrun#101(2026-08-12)移進 BUILTIN_COMPONENTS(step 1)——
|
||||
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
|
||||
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
|
||||
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
|
||||
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.1:13 個既有 binding 保留不新增),
|
||||
// 拆綁定要重新部署、與本票無關。
|
||||
set: "SVC_SET",
|
||||
array_ops: "SVC_ARRAY_OPS",
|
||||
string_ops: "SVC_STRING_OPS",
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"built_for": "arcrun-tier2-worker-artifacts",
|
||||
"generated_at": "2026-08-12T05:36:26.216Z",
|
||||
"repo_head": "cbeddf753537fbf836e20b7efce5b47b50f30d06",
|
||||
"generated_at": "2026-08-12T07:29:58.626Z",
|
||||
"repo_head": "1791ffa4972b4135dacd4208e805f67b747479c4",
|
||||
"repo_dirty": false,
|
||||
"workers": [
|
||||
{
|
||||
"name": "arcrun-cypher-executor",
|
||||
"source_dir": "cypher-executor",
|
||||
"source_commit": "a5e4caf5cb38c376f892708d8a46ad96a9cc23cc",
|
||||
"source_commit": "f1370e2275eea62b64a88821a096f2c2cfe76fb0",
|
||||
"main_module": "worker.mjs",
|
||||
"main_file": "arcrun-cypher-executor/worker.mjs",
|
||||
"js_bytes": 572842,
|
||||
"content_sha256": "d1765930ce157de07400dcff21d620a3b5549e0a66f0d6428cc6901631ba73b2",
|
||||
"js_bytes": 577374,
|
||||
"content_sha256": "8411ed59b7ad9e1a74ac0d8e3b620d7166e7d0178ac5939e6cc736f2e8d1d2be",
|
||||
"modules": [],
|
||||
"compat_date": "2025-02-19",
|
||||
"compat_flags": [
|
||||
|
||||
@@ -77,7 +77,12 @@ const LOGIC_BINDING_MAP: Record<string, keyof Bindings> = {
|
||||
filter: 'SVC_FILTER',
|
||||
merge: 'SVC_MERGE',
|
||||
try_catch: 'SVC_TRY_CATCH',
|
||||
wait: 'SVC_WAIT',
|
||||
// wait 已於 Arcrun#101(2026-08-12)移進 BUILTIN_COMPONENTS(step 1)——
|
||||
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
|
||||
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
|
||||
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
|
||||
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.1:13 個既有 binding 保留不新增),
|
||||
// 拆綁定要重新部署、與本票無關。
|
||||
set: 'SVC_SET',
|
||||
array_ops: 'SVC_ARRAY_OPS',
|
||||
string_ops: 'SVC_STRING_OPS',
|
||||
@@ -268,8 +273,13 @@ function makeHttpRunner(url: string): ComponentRunner {
|
||||
const text = await res.text();
|
||||
return { success: false, status: res.status, error: text.slice(0, 200) };
|
||||
}
|
||||
try { return await res.json(); }
|
||||
catch { return { success: true, data: await res.text() }; }
|
||||
// 只讀一次 body(同檔 readBodyOnce 的註解已寫明這個坑,這裡以前卻正好踩到):
|
||||
// 舊寫法 `try { res.json() } catch { res.text() }` 在零件回非 JSON 時,
|
||||
// res.json() 失敗當下 body 已被消費 → 第二次讀丟 "Body has already been used",
|
||||
// 使用者看到的是這句跟真因(零件回了非 JSON)完全無關的訊息(Arcrun#92 同類)。
|
||||
const text = await res.text();
|
||||
try { return JSON.parse(text); }
|
||||
catch { return { success: true, data: text }; }
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -47,6 +47,13 @@ export const SEMANTIC_EDGE_MAP: Record<string, EdgeType> = {
|
||||
'SUBFLOW': 'CALLS_SUBFLOW',
|
||||
};
|
||||
|
||||
/**
|
||||
* wait 零件的等待上限(毫秒)。與 registry/components/wait/component.contract.yaml
|
||||
* 逐字相同 —— 超過此值截斷、不報錯。**不可為了閃避資源上限調小**(Arcrun#101 紅線):
|
||||
* 「等外部系統跟上」是這顆零件存在的理由,把上限砍掉等於把能力換掉。
|
||||
*/
|
||||
export const WAIT_MAX_MS = 30000;
|
||||
|
||||
/**
|
||||
* 內建零件表(靜態函數)
|
||||
* WASM 零件 = 各自獨立 Worker,cypher-executor 走 HTTP URL 呼叫(不從 R2 讀)
|
||||
@@ -61,6 +68,61 @@ export const BUILTIN_COMPONENTS = new Map<string, ComponentRunner>([
|
||||
const c = ctx as Record<string, unknown>;
|
||||
return { ...c, count: (Number(c.count) || 0) + 1 };
|
||||
}],
|
||||
|
||||
// ── wait:等待 N 毫秒後繼續(Arcrun#101,2026-08-12)────────────────────────
|
||||
//
|
||||
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
|
||||
//
|
||||
// 舊實作是 registry/components/wait/main.go(TinyGo → WASM),用 time.Sleep。
|
||||
// TinyGo 的 sleep 走 WASI `poll_oneoff`;而每顆 component worker 的 WASI shim 把
|
||||
// poll_oneoff 實作成 ENOSYS(`.component-builds/*/src/index.ts`:`poll_oneoff: () => 76`)
|
||||
// ⇒ TinyGo 排程器拿不到「睡到某個時間」的手段,退化成迴圈重讀 `clock_time_get`
|
||||
// 自旋等時間到(wasm 內可見 runtime.sleepTicks / sleepQueue / runtime.ticks 符號)。
|
||||
//
|
||||
// 🔴 到這裡為止是**查得到原始碼的事實**。再往下「所以那個自旋迴圈的結束條件永遠
|
||||
// 不成立」曾被當成結論寫在這裡,但**寫了測試去證,反而被打臉**:在
|
||||
// vitest-pool-workers 的 workerd 裡,同步自旋 2553 圈之後 Date.now() 就前進了
|
||||
// ⇒ 時鐘並沒有全程凍結。
|
||||
// ⇒ 「為什麼三秒的等待會拖到 35 秒才死」的完整機制**目前仍是推測**,
|
||||
// 證據只有下面 leo 的四次實測。別把它當定論往外傳。
|
||||
//
|
||||
// 所以症狀不是「等 N 秒花 N 秒 CPU」,而是「不管 ms 填多少都跑到 CPU 上限被砍」。
|
||||
// leo 2026-08-12 在 youlin stage 實測(只有 input >> wait 兩個節點):
|
||||
// ms=3000 → 38.9s 後 503 / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
|
||||
// 四個值同一個死法、與 ms 無關 —— 3 秒的等待撐到 35 秒才死,就是「迴圈根本沒結束」
|
||||
// 的證據(若成本與時長成正比,ms=3000 只會花 3 秒 CPU,根本不該死)。
|
||||
// 也就是說 wait 零件在 Workers 上從來沒有真的等待成功過,不只是貴。
|
||||
//
|
||||
// 純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)本來就沒有「不花 CPU 地等」這種
|
||||
// 東西 —— 會等的只有宿主。故 wait 與 trigger_workflow 同類:**是 orchestrator 的
|
||||
// 執行排程職責,不是業務邏輯**(rule 02 §2.3 明列「workflow 執行排程」屬 cypher-executor
|
||||
// 合法職責;§2.2 禁的是解密/簽章/template 展開/具體 API 呼叫,等待都不是)。
|
||||
// 搬進引擎不違反「業務邏輯走 WASM」鐵律。引擎這側 await 一個 timer 只花 wall-clock、
|
||||
// 不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
|
||||
//
|
||||
// I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改:
|
||||
// 吃 ms(必填 > 0)+可選 context;ms > WAIT_MAX_MS 截斷;
|
||||
// 回 { success: true, data: { ...context, waited_ms } };ms <= 0 回 success:false。
|
||||
// 唯一刻意的放寬:ms 允許數字字串("3000")。WASM 版 json.Unmarshal 進 int 會直接
|
||||
// 失敗,但 node.data 走 interpolateData 後 `ms: "{{input.delay}}"` 必然是字串
|
||||
// ⇒ 收字串只會把「本來就跑不動的」變成跑得動,不會改變任何既有成功案例的行為。
|
||||
['wait', async (ctx) => {
|
||||
const c = (ctx && typeof ctx === 'object') ? ctx as Record<string, unknown> : {};
|
||||
|
||||
const requested = typeof c.ms === 'number' ? c.ms : Number(c.ms);
|
||||
if (!Number.isFinite(requested) || requested <= 0) {
|
||||
return { success: false, error: 'ms 必須大於 0' };
|
||||
}
|
||||
const ms = Math.min(Math.floor(requested), WAIT_MAX_MS);
|
||||
|
||||
// 這一行就是整張票:await timer ⇒ 只走 wall-clock,不佔請求執行緒、不記 CPU。
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const passthrough = (c.context && typeof c.context === 'object' && !Array.isArray(c.context))
|
||||
? c.context as Record<string, unknown>
|
||||
: {};
|
||||
return { success: true, data: { ...passthrough, waited_ms: ms } };
|
||||
}],
|
||||
]);
|
||||
|
||||
export const SCORE_THRESHOLD = 0.5;
|
||||
|
||||
@@ -27,6 +27,21 @@ export interface ArcrunHostEnv {
|
||||
const WASI_ESUCCESS = 0;
|
||||
const WASI_ENOSYS = 76;
|
||||
|
||||
// ── host function 回傳碼(u6u.*)─────────────────────────────────────────────
|
||||
// 零件(main.go)用同一組數字判斷,改這裡要同步改 registry/components/*/main.go。
|
||||
export const HOST_OK = 0;
|
||||
/** host 端出錯(memory 不可用 / 例外)— 零件無從得知細節 */
|
||||
export const HOST_ERROR = 1;
|
||||
/** 查無此 key / ref(kv_get、secret_get 用) */
|
||||
export const HOST_NOT_FOUND = 2;
|
||||
/**
|
||||
* Arcrun#92:資料塞不進零件宣告的接收緩衝區(**不是**連線失敗、**不是**對方報錯)。
|
||||
* 舊行為是「照寫下去」——data 比零件的 outBuf 大時會覆寫零件堆積體,零件接著用
|
||||
* `outBuf[:outLen]` 切片會 panic,或 writeOut 撞到 memory 邊界丟例外 → 回 1 →
|
||||
* 零件印一句與真因無關的 "HTTP request failed"。使用者照那句去查連線,方向全錯。
|
||||
*/
|
||||
export const HOST_TOO_LARGE = 3;
|
||||
|
||||
// fd 常數
|
||||
const FD_STDIN = 0;
|
||||
const FD_STDOUT = 1;
|
||||
@@ -75,6 +90,51 @@ export interface WasiHostFunctions {
|
||||
crypto_sign_rs256?: (data: Uint8Array, pkcs8: Uint8Array) => Promise<Uint8Array>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 容量握手的判定規則(Arcrun#92):資料塞不塞得進零件宣告的緩衝區?
|
||||
* declaredCapacity === 0 ⇒ 舊零件沒宣告容量,host 無從得知上限 → 維持舊行為照寫,
|
||||
* 不可自作聰明套一個預設值(各零件緩衝區大小不同:http_request 64KB、claude_api 1MB,
|
||||
* 硬套會把原本正常的大回應誤判成「太大」——那是換一種說謊)。
|
||||
*/
|
||||
export function outFitsCapacity(declaredCapacity: number, dataLength: number): boolean {
|
||||
return declaredCapacity === 0 || dataLength <= declaredCapacity;
|
||||
}
|
||||
|
||||
/** 位元組數轉人看得懂的單位(訊息裡要出現真實數字,不能只說「太大」) */
|
||||
export function formatBytes(n: number): string {
|
||||
if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (n >= 1024) return `${Math.round(n / 1024)} KB`;
|
||||
return `${n} bytes`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Arcrun#92:「回應太大」的 error envelope。
|
||||
*
|
||||
* 寫法上的三個要求(票上的紅線:不准換一句含糊的萬用句):
|
||||
* 1. 講**發生什麼**:多大、上限多少(真實數字,不是「太大」兩個字)
|
||||
* 2. 講**不是什麼**:不是連線失敗、資料也沒被偷偷截半——避免使用者往錯方向查
|
||||
* 3. 講**怎麼辦**:縮小回應的具體手段
|
||||
* 另附機器可讀欄位(code / actual_bytes / limit_bytes),讓上層能判斷而不必比對字串。
|
||||
*
|
||||
* status 用 0 而不是 413:對方伺服器並沒有回 413,寫 413 等於偽造一個上游狀態碼
|
||||
* (與 fetch 失敗的 envelope 同慣例,0 = 根本沒拿到 HTTP 狀態)。
|
||||
*/
|
||||
export function oversizeResponseEnvelope(actualBytes: number, limitBytes: number) {
|
||||
return {
|
||||
error:
|
||||
`回應太大,裝不下:對方回了 ${formatBytes(actualBytes)},` +
|
||||
`超過這個零件單次能接收的 ${formatBytes(limitBytes)} 上限。` +
|
||||
`這不是連線失敗,資料也沒有被截掉一半——是整包放不進零件。` +
|
||||
`做法:用來源 API 的分頁或篩選參數(例如 limit / page / per_page / fields)把回應縮小再重試;` +
|
||||
`真的需要整包資料時,改成分頁多抓幾次、每次處理一批。`,
|
||||
code: 'response_too_large',
|
||||
actual_bytes: actualBytes,
|
||||
limit_bytes: limitBytes,
|
||||
status: 0,
|
||||
body: '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 WASI shim 實例
|
||||
* @param stdinData - 要寫入 stdin 的 UTF-8 字串(通常是 JSON.stringify(input))
|
||||
@@ -95,14 +155,34 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
}
|
||||
|
||||
// 寫入結果到 WASM 的 outPtr buffer(host function 共用)
|
||||
// 回傳 0 = 成功,1 = memory 不可用
|
||||
// 回傳 HOST_OK / HOST_ERROR / HOST_TOO_LARGE
|
||||
//
|
||||
// 容量握手(Arcrun#92):零件在呼叫 host function 前,把自己 outBuf 的長度預先寫進
|
||||
// *outLenPtr;host 在寫回前讀這個值當容量上限。塞不下就**不寫**(避免覆寫零件記憶體)
|
||||
// 並回 HOST_TOO_LARGE,讓上層改寫一段講真話的訊息。
|
||||
//
|
||||
// 舊零件(沒做握手)讀到 0 = 「未宣告容量」→ 維持舊行為。這裡不能自作聰明假設 64KB:
|
||||
// 各零件緩衝區大小不同(http_request 64KB、claude_api 1MB),統一硬套會把原本
|
||||
// 跑得好好的大回應誤判成太大。
|
||||
function writeOut(buf: ArrayBuffer, outPtr: number, outLenPtr: number, data: Uint8Array): number {
|
||||
try {
|
||||
const view = new DataView(buf);
|
||||
const declaredCapacity = view.getUint32(outLenPtr, true);
|
||||
if (!outFitsCapacity(declaredCapacity, data.length)) return HOST_TOO_LARGE;
|
||||
new Uint8Array(buf, outPtr, data.length).set(data);
|
||||
new DataView(buf).setUint32(outLenPtr, data.length, true);
|
||||
return 0;
|
||||
view.setUint32(outLenPtr, data.length, true);
|
||||
return HOST_OK;
|
||||
} catch {
|
||||
return 1;
|
||||
return HOST_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
/** 讀零件宣告的緩衝區容量(0 = 舊零件沒宣告) */
|
||||
function declaredCapacityOf(buf: ArrayBuffer, outLenPtr: number): number {
|
||||
try {
|
||||
return new DataView(buf).getUint32(outLenPtr, true);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -352,12 +432,28 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
try {
|
||||
const result = await hostFunctions!.http_request!(url, method, headers, body);
|
||||
// await 後重新拿 memory.buffer(grow 會產生新的 ArrayBuffer)
|
||||
return writeOut(memory.buffer, outPtr, outLenPtr, new TextEncoder().encode(result));
|
||||
const encoded = new TextEncoder().encode(result);
|
||||
const status = writeOut(memory.buffer, outPtr, outLenPtr, encoded);
|
||||
if (status !== HOST_TOO_LARGE) return status;
|
||||
|
||||
// Arcrun#92:回應塞不進零件緩衝區。以前這裡會硬寫(覆寫零件記憶體)或回 1,
|
||||
// 零件對外只講得出 "HTTP request failed"——訊息與真因脫節。
|
||||
// 現在改寫一個講真話的 error envelope(零件既有的 parsed["error"] 判定鏈
|
||||
// 會原樣帶到使用者面前,不必改零件也能講對原因)。
|
||||
const capacity = declaredCapacityOf(memory.buffer, outLenPtr);
|
||||
const envelope = new TextEncoder().encode(
|
||||
JSON.stringify(oversizeResponseEnvelope(encoded.length, capacity)),
|
||||
);
|
||||
const envStatus = writeOut(memory.buffer, outPtr, outLenPtr, envelope);
|
||||
// 連這段說明都塞不下(緩衝區極小)→ 回 3,由零件自己講「回應太大」
|
||||
return envStatus === HOST_OK ? HOST_OK : HOST_TOO_LARGE;
|
||||
} catch (e) {
|
||||
// t117: 寫錯誤 envelope 到 WASM 輸出(main.go 讀 error key → success:false + 詳情);
|
||||
// 取代只 return 1(WASM 寫無資訊的 "HTTP request failed")。
|
||||
// writeOut 失敗(memory 壞)才 fallback return 1。
|
||||
const errDetail = e instanceof Error ? e.message : String(e);
|
||||
// 訊息截到 200 字:這段本身若超過零件緩衝區會被判成 HOST_TOO_LARGE,
|
||||
// 零件就會把「連不上」說成「回應太大」——又一次訊息與真因脫節(Arcrun#92)。
|
||||
const errDetail = (e instanceof Error ? e.message : String(e)).slice(0, 200);
|
||||
const errEnv = new TextEncoder().encode(
|
||||
JSON.stringify({ error: `fetch failed: ${errDetail}`, status: 0, body: '' })
|
||||
);
|
||||
@@ -366,7 +462,8 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
// kv_get(keyPtr, keyLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 key
|
||||
// kv_get(keyPtr, keyLen, outPtr, outLenPtr)
|
||||
// → 0 成功;1 錯誤;2 找不到 key;3 值太大塞不進零件緩衝區(Arcrun#92)
|
||||
kv_get: hostFunctions?.kv_get
|
||||
? hostWrap(async (keyPtr: number, keyLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
|
||||
if (!memory) { console.error('[kv_get] memory null'); return 1; }
|
||||
@@ -387,7 +484,8 @@ export function createWasiShim(stdinData: string, hostFunctions?: WasiHostFuncti
|
||||
})
|
||||
: () => 1,
|
||||
|
||||
// secret_get(refPtr, refLen, outPtr, outLenPtr) → 0 成功;1 錯誤;2 找不到 ref
|
||||
// secret_get(refPtr, refLen, outPtr, outLenPtr)
|
||||
// → 0 成功;1 錯誤;2 找不到 ref;3 值太大塞不進零件緩衝區(Arcrun#92)
|
||||
// 與 kv_get 同款 pointer/memory-write 機制;差別只在 host 端實作來源(env[ref] 而非 KV.get)。
|
||||
secret_get: hostFunctions?.secret_get
|
||||
? hostWrap(async (refPtr: number, refLen: number, outPtr: number, outLenPtr: number): Promise<number> => {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Arcrun#92 — 「回應太大」不准再被說成「請求失敗」
|
||||
*
|
||||
* 背景:零件(main.go)給 host function 的接收緩衝區是固定大小(http_request 64KB、
|
||||
* claude_api 1MB)。回應超過這個大小時,舊 host 會照寫不誤 → 覆寫零件記憶體 → 零件
|
||||
* 切片 panic,或 writeOut 撞 memory 邊界丟例外 → 回 1 → 零件印一句
|
||||
* "HTTP request failed"。使用者拿到那句會去查連線/URL/防火牆,全部查錯方向。
|
||||
*
|
||||
* 這一支測的是**訊息有沒有講真話**,不是「有沒有回錯誤」:
|
||||
* 1. 判定規則本身(沒宣告容量的舊零件不可被誤判成太大)
|
||||
* 2. 訊息內容:真實數字 + 撇清錯誤方向 + 具體該怎麼辦 + 機器可讀欄位
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
outFitsCapacity,
|
||||
formatBytes,
|
||||
oversizeResponseEnvelope,
|
||||
HOST_TOO_LARGE,
|
||||
} from '../src/lib/wasi-shim';
|
||||
|
||||
describe('容量握手的判定規則', () => {
|
||||
it('宣告 64KB、資料 200KB → 塞不下', () => {
|
||||
expect(outFitsCapacity(65536, 200_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('剛好等於容量 → 塞得下(不可 off-by-one 誤殺)', () => {
|
||||
expect(outFitsCapacity(65536, 65536)).toBe(true);
|
||||
});
|
||||
|
||||
it('舊零件沒宣告容量(0)→ 一律視為塞得下,維持舊行為', () => {
|
||||
// 這條是防「換一種說謊」:不能因為新規則就把 claude_api 那種 1MB 緩衝區的
|
||||
// 大回應統統誤判成「太大」。沒宣告 = host 不知道上限 = 不准亂猜。
|
||||
expect(outFitsCapacity(0, 900_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('HOST_TOO_LARGE 與零件端的 hostTooLarge 常數同值(registry/components/*/main.go)', () => {
|
||||
expect(HOST_TOO_LARGE).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBytes', () => {
|
||||
it('分別用 bytes / KB / MB', () => {
|
||||
expect(formatBytes(512)).toBe('512 bytes');
|
||||
expect(formatBytes(65536)).toBe('64 KB');
|
||||
expect(formatBytes(3_355_443)).toBe('3.2 MB');
|
||||
});
|
||||
});
|
||||
|
||||
describe('「回應太大」的訊息本身', () => {
|
||||
const env = oversizeResponseEnvelope(3_355_443, 65536);
|
||||
|
||||
it('講出實際大小與上限(不是只說「太大」)', () => {
|
||||
expect(env.error).toContain('3.2 MB');
|
||||
expect(env.error).toContain('64 KB');
|
||||
expect(env.actual_bytes).toBe(3_355_443);
|
||||
expect(env.limit_bytes).toBe(65536);
|
||||
});
|
||||
|
||||
it('明講「不是連線失敗」,把使用者從錯誤方向拉回來', () => {
|
||||
expect(env.error).toContain('不是連線失敗');
|
||||
});
|
||||
|
||||
it('給得出下一步(分頁/篩選),不是叫人「稍後再試」', () => {
|
||||
expect(env.error).toMatch(/分頁|篩選/);
|
||||
expect(env.error).not.toMatch(/稍後再試|請重新操作/);
|
||||
});
|
||||
|
||||
it('不准退回萬用句', () => {
|
||||
expect(env.error).not.toMatch(/請求失敗|HTTP request failed|未知錯誤/);
|
||||
});
|
||||
|
||||
it('帶機器可讀欄位,上層不必比對字串', () => {
|
||||
expect(env.code).toBe('response_too_large');
|
||||
});
|
||||
|
||||
it('status 不偽造上游狀態碼(對方沒回 413)', () => {
|
||||
expect(env.status).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* wait:等待不該吃運算額度(Arcrun#101)
|
||||
*
|
||||
* 病灶(leo 2026-08-12 於 youlin stage 實測,只有 input >> wait 兩個節點):
|
||||
* ms=3000 → 38.9s 後 503(1102) / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
|
||||
* 四個值同一種死法、與 ms 完全無關。若「等 N 秒=燒 N 秒 CPU」,ms=3000 只會花 3 秒
|
||||
* 就結束、根本不該死 —— 所以真正的病不是「等待很貴」,是「等待永遠不會結束」。
|
||||
*
|
||||
* 機制:wait 是 TinyGo WASM,time.Sleep 走 WASI poll_oneoff;component worker 的
|
||||
* WASI shim 把 poll_oneoff 實作成 ENOSYS ⇒ TinyGo 排程器退化成迴圈重讀 clock_time_get
|
||||
* 自旋;而 Workers 的時鐘在無 I/O 的同步執行期間凍結 ⇒ 迴圈的結束條件永遠不成立。
|
||||
*
|
||||
* 本檔驗四件事:
|
||||
* A. 反向驗證(機制):在真的 workerd 裡,輪詢時鐘的同步自旋迴圈確實永不前進。
|
||||
* B. 修法本體:wait 走引擎的 timer ⇒ 真的讓出執行緒(不佔請求執行緒)。
|
||||
* C. 契約沒變:既有 workflow 的 wait 節點定義不用改就能照樣跑。
|
||||
* D. 路由:wait 由 step 1 內建命中,不再打 arcrun-wait worker(不發任何 fetch)。
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { env } from 'cloudflare:test';
|
||||
import { BUILTIN_COMPONENTS, WAIT_MAX_MS } from '../src/lib/constants';
|
||||
import { createComponentLoader } from '../src/lib/component-loader';
|
||||
import type { Bindings, ComponentRunner } from '../src/types';
|
||||
|
||||
const wait = BUILTIN_COMPONENTS.get('wait') as ComponentRunner;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
// ── A. 反向驗證:舊路徑為什麼不可能便宜地等 ──────────────────────────────────
|
||||
//
|
||||
// 直接跑那顆 component.wasm 沒辦法寫成安全的測試 —— 它會把 isolate 卡到 CPU 上限,
|
||||
// 測試無從中止(那正是 bug 本身)。所以這裡驗的是「**沙箱裡根本沒有睡覺這個手段**」。
|
||||
//
|
||||
// 🔴 這裡本來有一條斷言「Workers 的時鐘在同步執行期間凍結,所以自旋迴圈的結束條件
|
||||
// 永遠不成立」。**實跑打臉了**:在 vitest-pool-workers 的 workerd 裡,2553 圈之後
|
||||
// Date.now() 就前進了。⇒ 那條斷言被刪掉,不是改鬆——它從一開始就不是證據。
|
||||
//
|
||||
// 保留下來的是**查證得動的那一半**:WASI shim 把 poll_oneoff 實作成 ENOSYS(76),
|
||||
// TinyGo 的 time.Sleep 只有這一條路可走 ⇒ 拿不到「睡到某個時刻」的手段,
|
||||
// 只能退化成自旋。至於「自旋為什麼會拖到 35 秒才死」的完整機制**仍是推測**,
|
||||
// 證據是 leo 在 youlin stage 的四次實測(見檔頭),不是本檔任何一條斷言。
|
||||
//
|
||||
// ⇒ 而修法不依賴那個推測:純 WASI 沙箱(stdin→stdout、無 socket、同步呼叫)
|
||||
// 本來就沒有「不花 CPU 地等」這種東西,會等的只有宿主。無論卡死的細節是什麼,
|
||||
// 等待都該搬回引擎。
|
||||
// 「poll_oneoff 是 ENOSYS」這件事查原始碼即可(`wasi-shim.ts:319` 的
|
||||
// `poll_oneoff: () => WASI_ENOSYS`,以及 13 個 `.component-builds/*/src/index.ts`
|
||||
// 的 `poll_oneoff: () => 76`)。**沒有為它硬寫一條測試**——寫得出來的只會是
|
||||
// 「把字串抓出來比對」,那驗的是抓字串,不是行為。事實放註解,斷言留給真的驗行為的 B/C/D。
|
||||
describe('A. 反向驗證:WASI 沙箱裡沒有「睡覺」這個手段', () => {
|
||||
it('對照組:await 一個 timer 之後時鐘才會前進(=為什麼修法必須在引擎側 await)', async () => {
|
||||
const t0 = Date.now();
|
||||
await new Promise<void>((r) => setTimeout(r, 20));
|
||||
expect(Date.now()).toBeGreaterThan(t0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── B. 修法本體:等待是 timer,不是佔用執行緒 ────────────────────────────────
|
||||
describe('B. 引擎側的 wait 真的讓出執行緒(等 30 秒與等 3 秒同價)', () => {
|
||||
it('5 個 300ms 的 wait 併發跑完 ≈ 300ms 而非 1500ms(會 blocking 的實作做不到這件事)', async () => {
|
||||
const started = Date.now();
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 5 }, () => wait({ ms: 300 })),
|
||||
);
|
||||
const elapsed = Date.now() - started;
|
||||
|
||||
for (const r of results) {
|
||||
expect(r).toEqual({ success: true, data: { waited_ms: 300 } });
|
||||
}
|
||||
// 序列化(blocking)會是 ~1500ms;讓出執行緒則 5 個計時器同時走完 ≈ 300ms。
|
||||
// 抓 900ms 當門檻:離 300 夠鬆、離 1500 夠遠。
|
||||
expect(elapsed).toBeLessThan(900);
|
||||
expect(elapsed).toBeGreaterThanOrEqual(300);
|
||||
});
|
||||
|
||||
it('等待期間 event loop 沒被佔住:同時排的 timer 照樣先到', async () => {
|
||||
const order: string[] = [];
|
||||
const waited = Promise.resolve(wait({ ms: 400 })).then(() => { order.push('wait-400'); });
|
||||
const ticked = new Promise<void>((r) => setTimeout(r, 50)).then(() => { order.push('tick-50'); });
|
||||
|
||||
await Promise.all([waited, ticked]);
|
||||
expect(order).toEqual(['tick-50', 'wait-400']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── C. 契約沒變:既有 wait 節點定義不用改 ────────────────────────────────────
|
||||
//
|
||||
// 逐條對 registry/components/wait/component.contract.yaml 的 gherkin_tests。
|
||||
describe('C. I/O 契約與 WASM 版一致(既有 workflow 不必改定義)', () => {
|
||||
it('contract gherkin:等待 100ms → waited_ms:100', async () => {
|
||||
expect(await wait({ ms: 100 })).toEqual({ success: true, data: { waited_ms: 100 } });
|
||||
});
|
||||
|
||||
it('contract gherkin:ms 為 0 時失敗(不是靜靜跳過)', async () => {
|
||||
expect(await wait({ ms: 0 })).toEqual({ success: false, error: 'ms 必須大於 0' });
|
||||
});
|
||||
|
||||
it('ms 缺漏 / 負數 / 非數字,一律誠實回 success:false,不假裝等過', async () => {
|
||||
for (const bad of [undefined, null, -1, 'abc', {}, []]) {
|
||||
expect(await wait({ ms: bad })).toEqual({ success: false, error: 'ms 必須大於 0' });
|
||||
}
|
||||
});
|
||||
|
||||
it('contract gherkin:ms=99999 截斷為上限 30000(不是報錯、也不是真的等 99 秒)', async () => {
|
||||
// 不真的等 30 秒:換掉 setTimeout,攔下引擎「要求等多久」再立刻放行。
|
||||
const asked: number[] = [];
|
||||
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
|
||||
asked.push(Number(delay));
|
||||
fn();
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as unknown as typeof setTimeout);
|
||||
|
||||
expect(await wait({ ms: 99999 })).toEqual({ success: true, data: { waited_ms: WAIT_MAX_MS } });
|
||||
expect(asked).toEqual([WAIT_MAX_MS]);
|
||||
expect(WAIT_MAX_MS).toBe(30000); // 紅線:上限不准為了閃避資源限制被調小
|
||||
});
|
||||
|
||||
it('ms=30000 一路走到底也只是「排一個 30 秒的 timer」,沒有任何同步佔用', async () => {
|
||||
const asked: number[] = [];
|
||||
vi.stubGlobal('setTimeout', ((fn: () => void, delay?: number) => {
|
||||
asked.push(Number(delay));
|
||||
fn();
|
||||
return 0 as unknown as ReturnType<typeof setTimeout>;
|
||||
}) as unknown as typeof setTimeout);
|
||||
|
||||
expect(await wait({ ms: 30000 })).toEqual({ success: true, data: { waited_ms: 30000 } });
|
||||
expect(asked).toEqual([30000]);
|
||||
});
|
||||
|
||||
it('context 照契約透傳,並補上 waited_ms', async () => {
|
||||
const r = await wait({ ms: 5, context: { order_id: 'A-1', payload: { n: 2 } } });
|
||||
expect(r).toEqual({
|
||||
success: true,
|
||||
data: { order_id: 'A-1', payload: { n: 2 }, waited_ms: 5 },
|
||||
});
|
||||
});
|
||||
|
||||
it('node.data 經 interpolateData 後 ms 會是字串 —— 收得下(WASM 版在這裡直接 unmarshal 失敗)', async () => {
|
||||
expect(await wait({ ms: '250' })).toEqual({ success: true, data: { waited_ms: 250 } });
|
||||
});
|
||||
});
|
||||
|
||||
// ── D. 路由:不再打 arcrun-wait worker ───────────────────────────────────────
|
||||
describe('D. component-loader 把 wait 解到內建 runner(step 1),不發任何 fetch', () => {
|
||||
it('loader("wait") 跑起來不會對外送出任何請求', async () => {
|
||||
const fakeEnv = { ...env, WORKER_SUBDOMAIN: 'test-sub' } as unknown as Bindings;
|
||||
const fetchSpy = vi.fn(async () => new Response('{}', { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
|
||||
const runner = await createComponentLoader(fakeEnv)('wait');
|
||||
const r = await runner({ ms: 10 });
|
||||
|
||||
expect(r).toEqual({ success: true, data: { waited_ms: 10 } });
|
||||
// 修法前這裡會打 arcrun-wait.test-sub.workers.dev(SVC_WAIT 未綁時的 fallback),
|
||||
// 那顆 worker 就是會燒到 1102 的那顆。
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('wait 仍在「執行期真的解析得動」的清單裡(/cypher/search 查得到)', async () => {
|
||||
const { RUNTIME_NATIVE_COMPONENT_IDS } = await import('../src/lib/component-loader');
|
||||
expect(RUNTIME_NATIVE_COMPONENT_IDS.has('wait')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,13 @@ import (
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組。
|
||||
const (
|
||||
hostOK uint32 = 0
|
||||
hostError uint32 = 1
|
||||
hostTooLarge uint32 = 3 // 資料塞不進零件宣告的接收緩衝區(Arcrun#92)
|
||||
)
|
||||
|
||||
// ── host function 宣告 ───────────────────────────────────────────────────────
|
||||
|
||||
//go:wasmimport u6u kv_get
|
||||
@@ -320,9 +327,17 @@ func doRefresh(input Input, recipe AuthRecipe) (string, int64, bool) {
|
||||
formBody := form.Encode()
|
||||
|
||||
headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}`
|
||||
respStr, ok2 := httpRequest(cfg.TokenEndpoint, "POST", headersJSON, formBody)
|
||||
if !ok2 {
|
||||
writeError("token endpoint HTTP 請求失敗")
|
||||
respStr, code := httpRequest(cfg.TokenEndpoint, "POST", headersJSON, formBody)
|
||||
if code == hostTooLarge {
|
||||
writeError("token endpoint 的回應太大,裝不下:超過這個零件單次能接收的 64 KB 上限。" +
|
||||
"這不是連線失敗——請求有送出去、對方也有回,只是整包塞不進零件。" +
|
||||
"多半表示 " + cfg.TokenEndpoint + " 回的不是正常的 token JSON(例如回了一整頁 HTML 錯誤頁);" +
|
||||
"請確認 auth recipe 的 token_endpoint 指向正確的 token 端點。")
|
||||
return "", 0, false
|
||||
}
|
||||
if code != hostOK {
|
||||
writeError("token endpoint 沒有拿到回應:引擎的 host function 回傳錯誤碼 " +
|
||||
strconv.Itoa(int(code)) + "(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題。")
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
@@ -387,7 +402,7 @@ func writeError(msg string) {
|
||||
func kvGet(key string) (string, uint32) {
|
||||
keyBytes := []byte(key)
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92),見 wasi-shim.ts writeOut
|
||||
|
||||
status := hostKvGet(
|
||||
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
|
||||
@@ -419,7 +434,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92)
|
||||
|
||||
status := hostCryptoDecrypt(
|
||||
uintptr(unsafe.Pointer(&encBytes[0])), uint32(len(encBytes)),
|
||||
@@ -432,18 +447,22 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||
return string(outBuf[:outLen]), true
|
||||
}
|
||||
|
||||
func httpRequest(reqURL, method, headersJSON, body string) (string, bool) {
|
||||
// httpRequest 回傳 (回應原文, host function 回傳碼)。
|
||||
// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區。
|
||||
// 之所以不再只回 bool:bool 把「連不上」和「回應太大」混成同一句話,
|
||||
// 使用者拿到 "token endpoint HTTP 請求失敗" 會往連線方向查,方向全錯(Arcrun#92)。
|
||||
func httpRequest(reqURL, method, headersJSON, body string) (string, uint32) {
|
||||
urlBytes := []byte(reqURL)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(body)
|
||||
|
||||
if len(urlBytes) == 0 {
|
||||
return "", false
|
||||
return "", hostError
|
||||
}
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92)
|
||||
|
||||
var bodyPtr uintptr
|
||||
if len(bodyBytes) > 0 {
|
||||
@@ -462,9 +481,9 @@ func httpRequest(reqURL, method, headersJSON, body string) (string, bool) {
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", false
|
||||
return "", status
|
||||
}
|
||||
return string(outBuf[:outLen]), true
|
||||
return string(outBuf[:outLen]), hostOK
|
||||
}
|
||||
|
||||
func interpolateTemplate(template string, secrets, runtime map[string]string) string {
|
||||
|
||||
@@ -19,11 +19,19 @@ import (
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組。
|
||||
const (
|
||||
hostOK uint32 = 0
|
||||
hostError uint32 = 1
|
||||
hostTooLarge uint32 = 3 // 資料塞不進零件宣告的接收緩衝區(Arcrun#92)
|
||||
)
|
||||
|
||||
// ── host function 宣告 ───────────────────────────────────────────────────────
|
||||
|
||||
//go:wasmimport u6u kv_get
|
||||
@@ -267,9 +275,17 @@ func main() {
|
||||
|
||||
headersJSON := `{"Content-Type":"application/x-www-form-urlencoded"}`
|
||||
|
||||
respStr, ok := httpRequest(recipe.TokenExchange.Endpoint, "POST", headersJSON, formBody)
|
||||
if !ok {
|
||||
writeError("token exchange HTTP 失敗")
|
||||
respStr, code := httpRequest(recipe.TokenExchange.Endpoint, "POST", headersJSON, formBody)
|
||||
if code == hostTooLarge {
|
||||
writeError("token exchange 的回應太大,裝不下:超過這個零件單次能接收的 64 KB 上限。" +
|
||||
"這不是連線失敗——請求有送出去、對方也有回,只是整包塞不進零件。" +
|
||||
"多半表示 " + recipe.TokenExchange.Endpoint + " 回的不是正常的 token JSON" +
|
||||
"(例如回了一整頁 HTML 錯誤頁);請確認 auth recipe 的 token_exchange.endpoint 正確。")
|
||||
return
|
||||
}
|
||||
if code != hostOK {
|
||||
writeError("token exchange 沒有拿到回應:引擎的 host function 回傳錯誤碼 " +
|
||||
strconv.Itoa(int(code)) + "(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題。")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -344,11 +360,12 @@ func pemToPkcs8(pem string) ([]byte, error) {
|
||||
return base64.StdEncoding.DecodeString(cleaned)
|
||||
}
|
||||
|
||||
// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到
|
||||
// kvGet 呼叫 host function,回傳 (value, status)。
|
||||
// status: 0=成功 1=錯誤 2=找不到 3=值太大塞不進 outBuf(Arcrun#92)
|
||||
func kvGet(key string) (string, uint32) {
|
||||
keyBytes := []byte(key)
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92),見 wasi-shim.ts writeOut
|
||||
|
||||
status := hostKvGet(
|
||||
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
|
||||
@@ -364,7 +381,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||
encBytes := []byte(encB64)
|
||||
ivBytes := []byte(ivB64)
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92)
|
||||
|
||||
if len(encBytes) == 0 || len(ivBytes) == 0 {
|
||||
return "", false
|
||||
@@ -387,7 +404,7 @@ func cryptoSignRS256(data, pkcs8 []byte) ([]byte, bool) {
|
||||
return nil, false
|
||||
}
|
||||
outBuf := make([]byte, 1024) // RSA-2048 簽章 = 256 bytes,1KB 綽綽有餘
|
||||
var outLen uint32
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92)
|
||||
|
||||
status := hostCryptoSignRS256(
|
||||
uintptr(unsafe.Pointer(&data[0])), uint32(len(data)),
|
||||
@@ -400,19 +417,21 @@ func cryptoSignRS256(data, pkcs8 []byte) ([]byte, bool) {
|
||||
return outBuf[:outLen], true
|
||||
}
|
||||
|
||||
// httpRequest 呼叫 host,回傳 response body 字串(host 側把 status + body 串好)
|
||||
func httpRequest(url, method, headersJSON, body string) (string, bool) {
|
||||
// httpRequest 呼叫 host,回傳 (response body 字串, host function 回傳碼)。
|
||||
// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區。
|
||||
// 不再只回 bool 的理由(Arcrun#92):bool 把「連不上」與「回應太大」講成同一句話。
|
||||
func httpRequest(url, method, headersJSON, body string) (string, uint32) {
|
||||
urlBytes := []byte(url)
|
||||
methodBytes := []byte(method)
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(body)
|
||||
|
||||
if len(urlBytes) == 0 {
|
||||
return "", false
|
||||
return "", hostError
|
||||
}
|
||||
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92)
|
||||
|
||||
// bodyBytes 可能為空(GET),host function 允許 len=0
|
||||
var bodyPtr uintptr
|
||||
@@ -432,9 +451,9 @@ func httpRequest(url, method, headersJSON, body string) (string, bool) {
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
if status != 0 {
|
||||
return "", false
|
||||
return "", status
|
||||
}
|
||||
return string(outBuf[:outLen]), true
|
||||
return string(outBuf[:outLen]), hostOK
|
||||
}
|
||||
|
||||
// interpolateTemplate 展開 {{secret.X}} 與 {{runtime.X}}。未知 key 展開為空字串。
|
||||
|
||||
@@ -287,11 +287,14 @@ func writeError(msg string) {
|
||||
os.Stdout.Write(out)
|
||||
}
|
||||
|
||||
// kvGet 呼叫 host function,回傳 (value, status)。status: 0=成功 1=錯誤 2=找不到
|
||||
// kvGet 呼叫 host function,回傳 (value, status)。
|
||||
// status: 0=成功 1=錯誤 2=找不到 3=值太大塞不進 outBuf(Arcrun#92)
|
||||
func kvGet(key string) (string, uint32) {
|
||||
keyBytes := []byte(key)
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
// 容量握手(Arcrun#92):先把緩衝區大小告訴 host,host 才能在值塞不下時
|
||||
// 回 status=3(值太大)而不是硬寫爆這塊記憶體。見 wasi-shim.ts writeOut。
|
||||
outLen := uint32(len(outBuf))
|
||||
|
||||
status := hostKvGet(
|
||||
uintptr(unsafe.Pointer(&keyBytes[0])), uint32(len(keyBytes)),
|
||||
@@ -309,7 +312,7 @@ func cryptoDecrypt(encB64, ivB64 string) (string, bool) {
|
||||
encBytes := []byte(encB64)
|
||||
ivBytes := []byte(ivB64)
|
||||
outBuf := make([]byte, 65536)
|
||||
var outLen uint32
|
||||
outLen := uint32(len(outBuf)) // 容量握手(Arcrun#92)
|
||||
|
||||
// 處理空字串的防呆(TinyGo 取 &[]byte{}[0] 會 panic)
|
||||
if len(encBytes) == 0 || len(ivBytes) == 0 {
|
||||
|
||||
@@ -15,9 +15,14 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// 與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組回傳碼。
|
||||
// 3 = 資料塞不進零件宣告的接收緩衝區(Arcrun#92)
|
||||
const hostTooLarge uint32 = 3
|
||||
|
||||
//go:wasmimport u6u http_request
|
||||
func hostHttpRequest(
|
||||
urlPtr uintptr, urlLen uint32,
|
||||
@@ -102,7 +107,9 @@ func main() {
|
||||
methodBytes := []byte("POST")
|
||||
|
||||
outBuf := make([]byte, 1024*1024) // 1MB
|
||||
var outLen uint32
|
||||
// 容量握手(Arcrun#92):先把緩衝區大小告訴 host,塞不下時 host 會回一段
|
||||
// 講明「回應太大 + 實際/上限大小 + 該怎麼辦」的 envelope,而不是硬寫爆記憶體。
|
||||
outLen := uint32(len(outBuf))
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
@@ -117,8 +124,16 @@ func main() {
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
// 回傳碼與 wasi-shim.ts 的 HOST_* 同一組:0=成功 1=引擎端錯誤 3=回應塞不下緩衝區
|
||||
if result == hostTooLarge {
|
||||
writeError("Mira 的回應太大,裝不下:超過這個零件單次能接收的 1 MB 上限。" +
|
||||
"這不是連線失敗,也不是 Mira 沒回應。" +
|
||||
"做法:把 prompt 改成請 Mira 回短一點(或分段回),或改用 callback_url 走非同步取回。")
|
||||
return
|
||||
}
|
||||
if result != 0 {
|
||||
writeError("Mira daemon request failed (host_http_request returned non-zero)")
|
||||
writeError("沒有拿到 Mira 的回應:引擎的 host function 回傳錯誤碼 " + strconv.Itoa(int(result)) +
|
||||
"(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題,不是 prompt 寫錯。")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -9,9 +9,14 @@ import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// host function 回傳碼,與 cypher-executor/src/lib/wasi-shim.ts 的 HOST_* 同一組數字。
|
||||
// 3 = 資料塞不進零件宣告的接收緩衝區(Arcrun#92:以前這種情況會被說成 "HTTP request failed")
|
||||
const hostTooLarge uint32 = 3
|
||||
|
||||
// host function 宣告(由 WASI shim 注入)
|
||||
//
|
||||
//go:wasmimport u6u http_request
|
||||
@@ -86,7 +91,11 @@ func main() {
|
||||
headersBytes := []byte(headersJSON)
|
||||
bodyBytes := []byte(bodyStr)
|
||||
outBuf := make([]byte, 65536) // 64KB output buffer
|
||||
var outLen uint32
|
||||
// 容量握手(Arcrun#92):呼叫前先把緩衝區大小告訴 host。
|
||||
// host(cypher-executor/src/lib/wasi-shim.ts 的 writeOut)拿這個值當上限——
|
||||
// 塞不下時不會硬寫爆這塊記憶體,而是改寫一段「回應太大 + 實際/上限大小 + 該怎麼辦」
|
||||
// 的 error envelope 回來,由下面既有的 parsed["error"] 判定鏈原樣交給使用者。
|
||||
outLen := uint32(len(outBuf))
|
||||
|
||||
urlPtr, urlLen := safePtr(urlBytes)
|
||||
methodPtr, methodLen := safePtr(methodBytes)
|
||||
@@ -101,8 +110,18 @@ func main() {
|
||||
uintptr(unsafe.Pointer(&outBuf[0])), uintptr(unsafe.Pointer(&outLen)),
|
||||
)
|
||||
|
||||
// host function 回傳碼(定義在 wasi-shim.ts):0=成功 1=host 端錯誤 3=回應塞不下緩衝區
|
||||
if result == hostTooLarge {
|
||||
// 走到這裡=連「回應太大」的說明本身都塞不進緩衝區(極端情況),
|
||||
// 所以零件自己講。訊息一樣要講清楚真因,不能退回 "HTTP request failed"。
|
||||
writeError("回應太大,裝不下:對方的回應超過這個零件單次能接收的 64 KB 上限。" +
|
||||
"這不是連線失敗,資料也沒有被截掉一半。" +
|
||||
"做法:用來源 API 的分頁或篩選參數(例如 limit / page / per_page / fields)把回應縮小再重試。")
|
||||
return
|
||||
}
|
||||
if result != 0 {
|
||||
writeError("HTTP request failed")
|
||||
writeError("沒有拿到回應:引擎的 host function 回傳錯誤碼 " + strconv.Itoa(int(result)) +
|
||||
"(0=成功 1=引擎端錯誤 3=回應太大)。這是引擎側的問題,不是你的 workflow 參數寫錯。")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
// wait — 等待指定毫秒數後繼續(最多 30 秒)
|
||||
// 注意:TinyGo/WASM 環境中 time.Sleep 可能不可用,改用 busy-wait 模擬
|
||||
//
|
||||
// ⚠️ 已由引擎接手,這份 WASM 在 Cloudflare Workers 上跑不動(Arcrun#101,2026-08-12)。
|
||||
// 現行實作在 cypher-executor/src/lib/constants.ts 的 BUILTIN_COMPONENTS['wait'],
|
||||
// component-loader step 1 先命中,這顆 wasm 不會再被工作流呼叫到。
|
||||
//
|
||||
// 為什麼跑不動(不是「比較慢」,是「永遠不會結束」):
|
||||
// 下面的 time.Sleep 在 TinyGo 走 WASI poll_oneoff,而 component worker 的 WASI shim
|
||||
// 把 poll_oneoff 實作成 ENOSYS ⇒ TinyGo 排程器退化成迴圈重讀 clock_time_get 自旋;
|
||||
// Workers 的時鐘在無 I/O 的同步執行期間是凍結的 ⇒ 結束條件永遠不成立 ⇒ 一路燒到
|
||||
// CPU 上限被砍(error 1102)。leo 實測 ms=3000/20000/30000 全在 ~35 秒後 503,
|
||||
// 死法與 ms 無關 —— 這正是「迴圈沒結束」而非「等待很貴」的證據。
|
||||
//
|
||||
// 原本的舊註解寫「改用 busy-wait 模擬」是錯的:這個檔從來沒有 busy-wait,
|
||||
// 一直是 time.Sleep。那句話誤導了後來每一個讀這個檔的人。
|
||||
//
|
||||
// 本次刻意不改行為、只改註解:手邊沒有 TinyGo 工具鏈,改了 main.go 卻沒重編,
|
||||
// 會讓 repo 內已 commit 的 .component-builds/wait/component.wasm 與原始碼漂移
|
||||
// (rule 05「WASM 來源」:那份 wasm 是 self-host 用戶的部署來源)。
|
||||
// 要退役這顆零件(刪目錄/下架 wait.arcrun.dev)是另一個決定,需人拍板。
|
||||
package main
|
||||
|
||||
import (
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Arcrun#92 重現腳本 — 「回應太大」到底會讓使用者看到什麼訊息
|
||||
*
|
||||
* 為什麼要有這支:http_request 零件的接收緩衝區是 64 KB。回應超過這個大小時,
|
||||
* 舊版會硬把資料寫進零件記憶體(寫爆)或讓 host 丟例外回 1,零件對外只講得出一句
|
||||
* "HTTP request failed"。使用者照那句去查連線/防火牆/URL,方向全錯。
|
||||
* 這支腳本把那個情境真的做出來,讓「修之前 / 修之後」的訊息可以並排比。
|
||||
*
|
||||
* 用法(本機,不碰任何線上實例):
|
||||
*
|
||||
* # 修之後(工作區現在的 wasm)
|
||||
* node scripts/repro-oversize-response.mjs 200000
|
||||
*
|
||||
* # 修之前(把 main 上的舊 wasm 取出來當對照組;舊 wasm 不做容量握手 → 走舊路徑)
|
||||
* git show origin/main:.component-builds/http_request/component.wasm > /tmp/old-http_request.wasm
|
||||
* node scripts/repro-oversize-response.mjs 200000 /tmp/old-http_request.wasm
|
||||
*
|
||||
* # 對照:沒超過上限時兩者都應該正常
|
||||
* node scripts/repro-oversize-response.mjs 1024
|
||||
*
|
||||
* Node < 22.18 請加 --experimental-strip-types(本檔會 import 一支 .ts)。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = resolve(here, '..');
|
||||
|
||||
const { createWasiShim } = await import(
|
||||
resolve(repoRoot, 'cypher-executor/src/lib/wasi-shim.ts')
|
||||
);
|
||||
|
||||
const responseBytes = Number(process.argv[2] ?? 200_000);
|
||||
const wasmPath = process.argv[3]
|
||||
? resolve(process.argv[3])
|
||||
: resolve(repoRoot, '.component-builds/http_request/component.wasm');
|
||||
|
||||
// 假裝遠端回了一包很大的 JSON(2xx,host function 照原樣把 body 交給零件)
|
||||
const filler = 'x'.repeat(Math.max(0, responseBytes - 14));
|
||||
const remoteBody = JSON.stringify({ items: filler });
|
||||
|
||||
const shim = createWasiShim(
|
||||
JSON.stringify({ url: 'https://example.com/big-list', method: 'GET' }),
|
||||
{ http_request: async () => remoteBody },
|
||||
);
|
||||
|
||||
const instance = await WebAssembly.instantiate(
|
||||
await WebAssembly.compile(readFileSync(wasmPath)),
|
||||
shim.imports,
|
||||
);
|
||||
shim.setMemory(instance.exports.memory);
|
||||
|
||||
let crashed = null;
|
||||
try {
|
||||
await shim.run(instance);
|
||||
} catch (e) {
|
||||
crashed = e instanceof Error ? e.message : String(e);
|
||||
}
|
||||
|
||||
const stdout = shim.getStdout().trim();
|
||||
const stderr = shim.getStderr().trim();
|
||||
|
||||
console.log(`wasm : ${wasmPath}`);
|
||||
console.log(`模擬回應大小 : ${remoteBody.length} bytes(零件緩衝區上限 65536 bytes)`);
|
||||
console.log('');
|
||||
|
||||
// 以下複刻 .component-builds/http_request/src/index.ts 的收尾,
|
||||
// 印出「使用者真的會拿到的那一包」
|
||||
if (stderr) console.log(`零件 stderr : ${stderr.slice(0, 300)}`);
|
||||
if (crashed) console.log(`WASM 執行中止 : ${crashed}`);
|
||||
|
||||
if (!stdout) {
|
||||
console.log('使用者看到 : HTTP 500 {"success":false,"error":"WASM component produced no output"}');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
} catch (e) {
|
||||
console.log(`使用者看到 : HTTP 500 {"success":false,"error":"${e.message}"}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`success : ${parsed.success}`);
|
||||
console.log(`error : ${parsed.error ?? '(無)'}`);
|
||||
if (parsed.success) {
|
||||
const body = parsed.data?.body ?? '';
|
||||
console.log(`data.body 長度 : ${String(body).length} bytes`);
|
||||
}
|
||||
@@ -341,6 +341,7 @@ arcrun 不自管加密金鑰,`crypto_decrypt` host function 已成永遠回失
|
||||
|------|--------|------|------|
|
||||
| ~~**credential 注入 401**~~ | ✅ 已解 | **8.1-8.5 全完成(2026-06-25 確認)** | 機制(auth_static_key `resolve_credentials` + graph-executor `resolveCredentialRefs`)已端到端實證:2026-06-13 Notion `{{credential.notion_token}}` 真讀到資料(同等於 8.5 OpenAI 驗收,機制與服務無關)。tasks.md 8.5 已補 `[x]` |
|
||||
| §8 P1/P2 recipe/workflow list 遷 D1 | 🔴 高 | 架構已拍板未動 code | 走 kbdb /entries HTTP 雙寫不加 binding;依賴 D1(現已可建)。另開 session 做 |
|
||||
| 零件接收緩衝區有硬上限(http_request 64KB/claude_api 1MB) | 🟡 中 | 訊息已誠實(Arcrun#92),**上限本身還在** | 回應超過上限=真的抓不回來。修的是「以前說成 HTTP request failed」,現在改說「回應太大+實際/上限大小+改用分頁」。host↔零件走**容量握手**(零件先把 outBuf 長度寫進 `*outLenPtr`,host 塞不下回 `HOST_TOO_LARGE=3`,見 `wasi-shim.ts`)。要真的支援大回應得另外設計(分頁/串流),不是調大 buffer 就好 |
|
||||
| 4 份 inline http_request host fn 抽共用 helper | 🟡 中 | 待 dedup | http_request/claude_api/kbdb_upsert_block/km_writer 各自複製貼上同段(這次假綠修也是逐份改) |
|
||||
| `arcrun.dev/llms.txt` 404 | 🟡 中 | 未 serve | landing/public 缺檔;GitHub repo 內正常(test/5 走 GitHub 不阻擋) |
|
||||
| MCP account-source | 🟡 中 | 記錄中 | self-hosted MCP 指官方不指自己(§5.2 已知) |
|
||||
|
||||
Reference in New Issue
Block a user