Compare commits

..

5 Commits

Author SHA1 Message Date
uncle6me-web 8e9bd09072 fix(engine): 回應太大就說「回應太大」——不再冒充「請求失敗」(Arcrun#92)
真因:零件(main.go)給 host function 的接收緩衝區是固定大小(http_request 64KB、
claude_api 1MB)。回應超過這個大小時,wasi-shim 的 writeOut 照寫不誤:

    new Uint8Array(buf, outPtr, data.length).set(data)

data 比零件的 outBuf 大 → 覆寫零件堆積體,零件接著 outBuf[:outLen] 切片 panic;
或 writeOut 撞 memory 邊界丟例外 → 回 1 → 零件印一句 "HTTP request failed"。
使用者照那句去查連線/URL/防火牆,方向全錯。

修法(容量握手,不改 host function 簽名、向後相容):
- 零件呼叫前把 outBuf 長度預先寫進 *outLenPtr(宣告容量)
- host 在寫回前讀這個值當上限;塞不下就**不寫**(不再覆寫零件記憶體),回新的
  HOST_TOO_LARGE=3
- http_request host fn 收到 3 → 改寫一段講真話的 error envelope(實際大小+上限+
  「不是連線失敗」+分頁/篩選的具體做法+機器可讀 code/actual_bytes/limit_bytes),
  沿用既有 parsed["error"] 判定鏈原樣送到使用者面前
- 舊零件沒宣告容量(讀到 0)→ 維持舊行為。不可硬套 64KB 預設:各零件緩衝區大小不同,
  硬套會把原本正常的大回應誤判成「太大」,那只是換一種說謊

同一條路徑上另一個「訊息與真因脫節」一併修:component-loader 的 makeHttpRunner
`try res.json() catch res.text()`,在零件回非 JSON 時 body 已被消費 → 丟
"Body has already been used",與真因無關(同檔 readBodyOnce 的註解早就寫明這個坑)。
改成只讀一次。

驗證狀態(誠實標示,mindset §7):
- 通:5 顆零件 tinygo build 全過,wasm 已重編進 .component-builds/
  (claude_api 依 .gitignore 慣例不入庫,由部署端重編)
- 未跑:runtime 驗證。本 session 的權限層擋掉 node/vitest/wasmtime,
  before/after 實測輸出待人跑 scripts/repro-oversize-response.mjs
- 未做:.worker-builds/ 重編(需 node scripts/build-worker-artifacts.mjs),
  否則修法不會進 self-hosted 安裝路徑(Arcrun#93 同款陷阱)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 16:49:22 +08:00
uncle6me-web a24f2912eb chore(builds): 重編執行檔——把 wait 的修法真的放進引擎成品(Arcrun#93 的閘抓到的)
#101 併進 main 後,.worker-builds/arcrun-cypher-executor 還記著 a5e4caf,
比源碼 HEAD 少了 f1370e2(wait 搬回引擎那筆)。

⇒ 這正是 #93 那道閘存在的理由:**沒有它,這次出貨會送出一個
「引擎裡沒有 wait 修法」的成品**——leo 更新完照樣燒 CPU,而出貨線全綠。
閘寫出來的隔天就抓到一次,抓到的還是我。

arcrun-cypher-executor  source a5e4caff1370e22  sha256=8411ed59b7ad

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:30:26 +08:00
Leo 1791ffa497 Merge pull request '#101 等待搬回引擎——WASI 沙箱裡沒有「不花 CPU 地等」這種東西' (#103) from fix/wait-not-in-wasm-101 into main 2026-08-12 07:26:00 +00:00
Leo 296fb01247 Merge pull request 'portal 安裝完成清單拿掉「去 Google 申請 AI 金鑰」那一項(arcrun-rag#81)' (#102) from fix/portal-onboarding-drop-gemini-key-81 into main 2026-08-12 07:24:47 +00:00
uncle6me-web f1370e2275 fix(engine): 等待搬回引擎——WASI 沙箱裡沒有「不花 CPU 地等」這種東西(Arcrun#101)
leo 在 youlin stage 實測(只有 input >> wait 兩個節點):
  ms=3000 → 38.9s 後 503(1102) / ms=20000 → 34.0s / ms=30000 → 34.9s / 寫死 3000 → 34.8s
四個值同一種死法、與 ms 無關 ⇒ 病不是「等待很貴」,是「等待從來沒成功過」。

修法:wait 移進 BUILTIN_COMPONENTS,由引擎 await 一個 timer。
只花 wall-clock、不記 CPU ⇒ 等 30 秒與等 3 秒同價(皆 ≈0)。
I/O 契約沿用 component.contract.yaml,既有 workflow 的 wait 節點定義不必改。

🔴 誠實標明:原本註解斷言「Workers 時鐘在同步執行期間凍結,所以自旋永不結束」。
寫測試去證,反而被打臉——workerd 裡自旋 2553 圈後 Date.now() 就前進了。
那條假斷言已刪除(不是改鬆),完整機制降級為推測。修法不依賴它:
純 WASI 沙箱本來就沒有睡覺這個手段,會等的只有宿主。

實測:
  npx vitest run tests/wait-builtin.test.ts  → 12 passed (12)
  npx vitest run(全套)                      → 386 passed / 14 failed
                                              (14 = 動工前的既有紅燈數,未新增)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 15:15:08 +08:00
19 changed files with 702 additions and 49 deletions
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#1012026-08-12)────────────────────────
//
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
//
// 舊實作是 registry/components/wait/main.goTinyGo → 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)+可選 contextms > 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#1012026-08-12)移進 BUILTIN_COMPONENTSstep 1)——
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.113 個既有 binding 保留不新增),
// 拆綁定要重新部署、與本票無關。
set: "SVC_SET",
array_ops: "SVC_ARRAY_OPS",
string_ops: "SVC_STRING_OPS",
+5 -5
View File
@@ -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": [
+13 -3
View File
@@ -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#1012026-08-12)移進 BUILTIN_COMPONENTSstep 1)——
// 等待是 orchestrator 的排程職責,WASI 沙箱裡做不到「不花 CPU 地等」。理由全文見
// constants.ts 的 wait 註解。這裡刻意**移除**而非留著:step 1 本來就先於 step 5 命中,
// 留下這行只會讓讀者以為 wait 還走 SVC_WAIT(實際永遠走不到)=誤導人的死路由。
// wrangler.toml 的 SVC_WAIT binding 不動(rule 3.113 個既有 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 }; }
};
}
+62
View File
@@ -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 零件 = 各自獨立 Workercypher-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#1012026-08-12)────────────────────────
//
// 為什麼「等待」搬進引擎,而不是修那顆 WASM:
//
// 舊實作是 registry/components/wait/main.goTinyGo → 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)+可選 contextms > 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;
+106 -8
View File
@@ -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 / refkv_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 bufferhost function 共用)
// 回傳 0 = 成功,1 = memory 不可用
// 回傳 HOST_OK / HOST_ERROR / HOST_TOO_LARGE
//
// 容量握手(Arcrun#92):零件在呼叫 host function 前,把自己 outBuf 的長度預先寫進
// *outLenPtrhost 在寫回前讀這個值當容量上限。塞不下就**不寫**(避免覆寫零件記憶體)
// 並回 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.buffergrow 會產生新的 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 1WASM 寫無資訊的 "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);
});
});
+165
View File
@@ -0,0 +1,165 @@
/**
* waitArcrun#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 CPUms=3000 3
*
*
* wait TinyGo WASMtime.Sleep WASI poll_oneoffcomponent 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 gherkinms 為 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 gherkinms=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 解到內建 runnerstep 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.devSVC_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);
});
});
+29 -10
View File
@@ -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=值太大塞不進 outBufArcrun#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 展開為空字串。
+6 -3
View File
@@ -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=值太大塞不進 outBufArcrun#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 {
+17 -2
View File
@@ -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
}
+21 -2
View File
@@ -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。
// hostcypher-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
}
+19 -1
View File
@@ -1,5 +1,23 @@
// wait — 等待指定毫秒數後繼續(最多 30 秒)
// 注意:TinyGo/WASM 環境中 time.Sleep 可能不可用,改用 busy-wait 模擬
//
// ⚠️ 已由引擎接手,這份 WASM 在 Cloudflare Workers 上跑不動(Arcrun#1012026-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 (
+91
View File
@@ -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');
// 假裝遠端回了一包很大的 JSON2xxhost 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`);
}
+1
View File
@@ -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 64KBclaude_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 已知) |