Files
Arcrun/scripts/repro-oversize-response.mjs
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

92 lines
3.4 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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`);
}