8e9bd09072
真因:零件(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>
80 lines
3.0 KiB
TypeScript
80 lines
3.0 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|