f1370e2275
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>
166 lines
8.9 KiB
TypeScript
166 lines
8.9 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|
||
});
|