Files
Arcrun/cypher-executor/tests/oversize-response-truth.test.ts
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

80 lines
3.0 KiB
TypeScript
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 — 「回應太大」不准再被說成「請求失敗」
*
* 背景:零件(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);
});
});