From 040d0e78d2b952c4d388e455a44d3d39c719d168 Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:58:44 +0800 Subject: [PATCH 1/3] fix(onlion): a stalled request body can no longer hold an invocation to the ceiling The 19:34-19:50 window split its twelve 120 s kills in two: seven at 601-667 MB (business logic, the embedding chain) and five at a constant 51.3359 MB - a number that never moved, which is what an invocation looks like when it is still waiting for its request body. readJson awaited reader.read() with no deadline of its own, so an upload that stopped halfway held the whole budget and was then killed with nothing to show. readJson now gives the body BODY_TIMEOUT_MS (30 s, NMG_BODY_TIMEOUT_MS to change) and answers 408 request_body_timeout, cancelling the stream. Also: the 60 s slow-request line reports `stage_isolate`, the measured stage the isolate is currently inside. Every 120 s kill so far needed a code read to say where it was; one string assignment per measured stage turns that into a field. On this platform (fresh isolate per request) the reading is exact; where an isolate is reused it is approximate, which the field name says. Both are additive; 37 PASS. --- onlion/backend/diagnostics.mjs | 26 +++++++++++++++++++++-- onlion/backend/worker.mjs | 39 +++++++++++++++++++++++++--------- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/onlion/backend/diagnostics.mjs b/onlion/backend/diagnostics.mjs index 91eff8cf..8b451183 100644 --- a/onlion/backend/diagnostics.mjs +++ b/onlion/backend/diagnostics.mjs @@ -1,13 +1,35 @@ import {observedStage} from './monitoring.mjs'; import { AsyncLocalStorage } from 'node:async_hooks'; const profiles = new AsyncLocalStorage(); +/** The stage this isolate is currently inside. + * + * The 60 s slow-request line reported memory, queue state and the last embedding call, and every + * 120 s kill in the 2026-10-30 windows still needed a code read to say where it was. One string + * assignment per measured stage fixes that: a fresh isolate per request on this platform means + * there is no cross-request ambiguity, and a reused one only makes it approximate, which the log + * says explicitly by naming the field `stage_isolate`. + */ +let activeStage = null; +export const currentStage = () => activeStage; function timedMeasure(name, operation) { const profile = profiles.getStore(); - if (!profile) return operation(); + if (!profile) { + const prior = activeStage; + activeStage = name; + return (async () => { + try { + return await operation(); + } finally { + activeStage = prior; + } + })(); + } return (async()=>{ const start = performance.now(); + const prior = activeStage; + activeStage = name; try { return await operation(); } - finally { profile[name] = (profile[name] || 0) + performance.now() - start; } + finally { activeStage = prior; profile[name] = (profile[name] || 0) + performance.now() - start; } })(); } function timedMeasureSync(name, operation) { diff --git a/onlion/backend/worker.mjs b/onlion/backend/worker.mjs index 698f6175..35bbf5c8 100644 --- a/onlion/backend/worker.mjs +++ b/onlion/backend/worker.mjs @@ -24,6 +24,7 @@ import { requestAccepted, requestStarted, requestFinished, + currentStage, } from "./diagnostics.mjs"; import init from "../sqlite-wasm/worker-loader.mjs"; import compiledSqlite from "../sqlite-wasm/sqlite3.wasm"; @@ -87,6 +88,14 @@ import { } from "./query-chunks.mjs"; const MAX_DATABASE_BYTES = USER_LIMIT; const MAX_BODY_BYTES = 6 * 1024 * 1024; +/** How long one request may spend receiving its body. + * + * The platform kills an invocation at 120 s, so a body that never completes used to occupy the + * whole budget and then die with nothing to show - the 51.3359 MB constant rows of the + * 2026-10-30 19:34-19:50 window, which never allocated because the handler was still waiting for + * the upload. Thirty seconds is generous for 6 MiB and leaves the rest of the window for the work + * the body asked for. */ +const BODY_TIMEOUT_MS = 30_000; /** Payload caps are sized against the platform request body, not against a single * global character budget. The benchmark contract states there is no global * character cap and that an unsupported payload should get a clear 4xx, so a cap @@ -218,15 +227,24 @@ function authenticate(request, env) { "A valid Token, Bearer, or X-API-Key credential is required", ); } -async function readJson(request) { +async function readJson(request, env) { if (Number(request.headers.get("content-length")) > MAX_BODY_BYTES) throw new HttpError(413, "request_too_large", "Request limit is 6 MiB"); if (!request.body) throw new HttpError(400, "invalid_json", "JSON body required"); + // Nothing below this line has a deadline of its own, so this is the only place that can give a + // stalled upload one instead of holding the invocation to the platform ceiling. + const deadline = + Date.now() + + (Number(env?.NMG_BODY_TIMEOUT_MS) > 0 ? Number(env.NMG_BODY_TIMEOUT_MS) : BODY_TIMEOUT_MS); const reader = request.body.getReader(); const chunks = []; let length = 0; try { while (true) { + if (Date.now() > deadline) { + await reader.cancel().catch(() => {}); + throw new HttpError(408, "request_body_timeout", "Request body did not arrive in time"); + } const { done, value } = await reader.read(); if (done) break; length += value.length; @@ -1266,6 +1284,7 @@ function watchSlowRequest(env, context, started) { route: context.route, elapsed_ms: Date.now() - started, add_mode: env.NMG_ADD_MODE ?? null, + stage_isolate: currentStage(), queue: queueStatus(), // A killed invocation carries none of this, so the report has to be written while // the request is still alive: memory, how many requests this instance has served, @@ -1700,7 +1719,7 @@ async function reclaimAllRoute(request, env, path) { throw new HttpError(503, "storage_unavailable", "Persistent storage binding missing"); if (request.method === "GET") return json(await reclamationStatus(env.MEMORY)); if (request.method === "POST") { - const input = await readJson(request); + const input = await readJson(request, env); if (input.confirm !== "ALL_MEMORY") throw new HttpError( 400, @@ -1800,7 +1819,7 @@ async function selfTestRoute(request, env, _url, path) { if (path !== "/diagnostics/self-test" || request.method !== "POST") return undefined; assertSelfTestAccess(request, env); env = { ...env, MEMORY: await memoryBucket(env.MEMORY) }; - const input = await readJson(request); + const input = await readJson(request, env); if (Object.keys(input).some((k) => !["op", "persistence", "repetitions"].includes(k))) throw new HttpError( 400, @@ -1984,7 +2003,7 @@ async function adminRoute(request, env, url, path) { } async function adminRouteDispatcher(request, env, url, path) { if (path === "/admin/statistics" && request.method === "POST") { - const input = await readJson(request); + const input = await readJson(request, env); if (typeof input.enabled !== "boolean") throw new HttpError(400, "invalid_request", "enabled must be boolean"); return json({ load: setStatistics(input.enabled) }); @@ -1992,14 +2011,14 @@ async function adminRouteDispatcher(request, env, url, path) { if (path === "/admin/capacity" && request.method === "GET") return json(await capacityReport(env, textField(url.searchParams.get("user_id"), "user_id"))); if (path === "/admin/reset" && request.method === "POST") - return json(await reset(env, await readJson(request))); + return json(await reset(env, await readJson(request, env))); if (path === "/admin/key" && request.method === "GET") return json({ api_key: env.NMG_API_KEY }); if (path === "/admin/status" && request.method === "GET") return adminStatusResponse(env); return adminDiagnosticsRoute(request, env, path); } async function adminDiagnosticsRoute(request, env, path) { if (path === "/admin/diagnostics/search" && request.method === "POST") { - const input = await readJson(request); + const input = await readJson(request, env); return json(await diagnosticSearch(env, input)); } if (path === "/admin/diagnostics/seed" && request.method === "POST") { @@ -2021,7 +2040,7 @@ async function adminDiagnosticsRoute(request, env, path) { } async function searchRoute(request, env, _url, path) { if (path !== "/search" || request.method !== "POST") return undefined; - const input = await readJson(request); + const input = await readJson(request, env); if (request.headers.get("x-nmg-profile") === "1") { const { result, ...diagnostics } = await profileSearch(() => search(env, input)); return json({ ...result, diagnostics }); @@ -2046,7 +2065,7 @@ async function memoryApiRoute(request, env, url, path) { if (!env.MEMORY) throw new HttpError(503, "storage_unavailable", "Persistent storage binding missing"); if ((path === "/reclaim" || path === "/reclaim/user") && request.method === "POST") { - const input = await readJson(request), + const input = await readJson(request, env), userId = textField(input.user_id, "user_id"), requestId = textField(input.request_id, "request_id", 128); const result = await reset(env, { @@ -2070,7 +2089,7 @@ async function memoryApiRoute(request, env, url, path) { } async function resetRoute(request, env, path) { if (path !== "/reset" || request.method !== "POST") return undefined; - return json(await reset(env, await readJson(request))); + return json(await reset(env, await readJson(request, env))); } async function capacityRoute(request, env, url, path) { if (path !== "/capacity" || request.method !== "GET") return undefined; @@ -2109,7 +2128,7 @@ async function foldRoute(request, env, url, path) { } async function addRoute(request, env, path) { if ((path !== "/add" && path !== "/remember") || request.method !== "POST") return undefined; - const input = await readJson(request); + const input = await readJson(request, env); if (addMode(env) !== "log") return json(await add(env, input)); const startedAt = Date.now(); const accepted = await acceptAdd(env, input); From 5747132f667b7d59a83d33673e8fb2ebfe5e0bda Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Fri, 30 Oct 2026 19:59:31 +0800 Subject: [PATCH 2/3] docs(decision): a stalled request body cannot hold an invocation --- .../2026-10-30-body-deadline-and-stage.md | 71 +++++++++++++++++++ ...6-10-30-body-deadline-and-stage.terms.yaml | 4 ++ ...026-10-30-body-deadline-and-stage.zh-CN.md | 49 +++++++++++++ 3 files changed, 124 insertions(+) create mode 100644 docs/decisions/implemented/2026-10-30-body-deadline-and-stage.md create mode 100644 docs/decisions/implemented/2026-10-30-body-deadline-and-stage.terms.yaml create mode 100644 docs/decisions/implemented/2026-10-30-body-deadline-and-stage.zh-CN.md diff --git a/docs/decisions/implemented/2026-10-30-body-deadline-and-stage.md b/docs/decisions/implemented/2026-10-30-body-deadline-and-stage.md new file mode 100644 index 00000000..1618e9a2 --- /dev/null +++ b/docs/decisions/implemented/2026-10-30-body-deadline-and-stage.md @@ -0,0 +1,71 @@ +# A stalled request body cannot hold an invocation, and slow reports name their stage + +[简体中文](2026-10-30-body-deadline-and-stage.zh-CN.md) · [Terms](2026-10-30-body-deadline-and-stage.terms.yaml) + +**Status:** implemented +**Approved:** explicit + +## Problem + +The 19:34-19:50 window has twelve kills at the 120 s ceiling, and they split on memory: + +| group | memory | rows | reading | +| ----- | ------------------------ | ---------------------- | --------------------------------------------------------------------------------------------------------------- | +| X | 601-667 MB | 7, inside 153 s | business logic ran; the embedding chain (already addressed by the per-invocation breaker and the fold deadline) | +| Y | **51.3359 MB, constant** | 5, spread over 9.4 min | memory never moved - the invocation was still waiting for its **request body** | + +`readJson` awaited `reader.read()` with **no deadline of its own**. A client that stops +uploading halfway (or an edge that holds the connection) therefore occupies the entire invocation +budget, allocates nothing, and is killed at 120 s with no trace of what it wanted. The platform's own +per-invocation memory figure is the only fingerprint, and it says "nothing ever happened". + +The second problem is diagnostic: every 120 s kill so far needed a code read to say where it was. +The 60 s slow report carries memory, queue state and the last embedding call, but not the stage the +isolate is sitting in. + +## Decision + +- `readJson(request, env)` gives the body `BODY_TIMEOUT_MS` (30 s, `NMG_BODY_TIMEOUT_MS`), cancels + the stream and answers `408 request_body_timeout`. Thirty seconds is generous for the 6 MiB limit + and leaves most of the invocation for the work the body asked for. +- `diagnostics.mjs` tracks `activeStage`: the name of the measured stage currently executing, set + and restored around `measure`/`measureSync`, exposed as `currentStage()`. The slow-request line + reports it as `stage_isolate`. + +## Evidence + +| before | after | +| ---------------------------------------------------------- | --------------------------------------------------------------------------------- | +| 5 rows at 120 s with memory frozen at the runtime baseline | the body read fails at 30 s with `408 request_body_timeout` and a stage of `null` | +| a 120 s kill names only memory and the last embedding call | the 60 s self-report names the stage the isolate was inside | + +## What this does and does not establish + +- It does not prove those five rows were stalled uploads. Memory that never moves is consistent with + it, not proof: an edge-side wait that never reaches the handler would look the same and would leave + **no** `nmg_request` line at all. The stage field plus the absence of a request line is what + distinguishes the two next window. +- The X group is untouched here. Its mechanism (embedding timeouts, single-instance serialization) + is documented in `2026-10-30-embedding-breaker` and `postmortem/0006`; the remaining lever there + is the provider and the harness concurrency. +- `stage_isolate` is exact on this platform (fresh isolate per request) and approximate if an isolate + is ever reused for concurrent requests, which is why the field names the isolate rather than the + request. + +## Alternatives considered + +- **Rely on the platform's 120 s kill.** That is what happens today: a stalled body turns one + request into a full-budget orphan and tells us nothing. +- **Read the body with a `Promise.race` timeout instead of a deadline check.** A race leaves the + reader pumping into a dead promise; checking before each read and cancelling the stream is the + version that actually releases the connection. +- **Infer the X/Y groups from memory alone.** Memory is a fingerprint, not evidence: a request that + is killed during its first provider call and one killed while waiting for a body differ by more + than a number, which is why the stage field is part of this change. + +## Consequences + +- A stalled upload now costs 30 s and returns a clear 408 instead of consuming the invocation. +- Log lines gain one string per slow request; nothing per ordinary request changes. +- The next 120 s window is diagnosable: `stage_isolate` says what the isolate was doing, and a + missing `nmg_request` line says the request never reached us at all. diff --git a/docs/decisions/implemented/2026-10-30-body-deadline-and-stage.terms.yaml b/docs/decisions/implemented/2026-10-30-body-deadline-and-stage.terms.yaml new file mode 100644 index 00000000..bc9be61d --- /dev/null +++ b/docs/decisions/implemented/2026-10-30-body-deadline-and-stage.terms.yaml @@ -0,0 +1,4 @@ +version: 2 +terms: [] +references: + - docs/decisions/implemented/2026-10-30-body-deadline-and-stage.md diff --git a/docs/decisions/implemented/2026-10-30-body-deadline-and-stage.zh-CN.md b/docs/decisions/implemented/2026-10-30-body-deadline-and-stage.zh-CN.md new file mode 100644 index 00000000..e5156007 --- /dev/null +++ b/docs/decisions/implemented/2026-10-30-body-deadline-and-stage.zh-CN.md @@ -0,0 +1,49 @@ +# 停滞的请求体不能占满一次调用;慢请求自报要写清阶段 + +[English](2026-10-30-body-deadline-and-stage.md) · [Terms](2026-10-30-body-deadline-and-stage.terms.yaml) + +**Status:** implemented +**Approved:** explicit + +## 问题 + +19:34–19:50 窗口有 12 条 120 s 超时,按内存分成两组: + +| 组 | 内存 | 条数 | 判读 | +| --- | -------------------- | --------------------- | ---------------------------------------------------------- | +| X | 601–667 MB | 7 条,集中在 153 秒内 | 进了业务逻辑;embedding 链(已由按调用熔断与折叠截止处理) | +| Y | **51.3359 MB,恒定** | 5 条,分散在 9.4 分钟 | 内存从未增长——调用一直在等**请求体** | + +`readJson` 里 `await reader.read()` **没有任何截止时间**。客户端传一半就停(或边缘把连接挂住),于是整段调用预算被一个还没到达的 body 占住,不分配内存,最后在 120 s 被杀,什么线索都不留。平台给出的每调用内存是唯一指纹,而它说的是"什么都没发生"。 + +第二个问题是可观测性:到目前为止每次 120 s 被杀都要读代码才知道卡在哪。60 s 慢请求自报有内存、队列状态和最近一次嵌入调用,但没有"当前阶段"。 + +## 决策 + +- `readJson(request, env)` 给请求体 `BODY_TIMEOUT_MS`(30 秒,`NMG_BODY_TIMEOUT_MS` 可改),超时取消流并返回 `408 request_body_timeout`。对 6 MiB 上限而言 30 秒很宽裕,剩下的时间留给 body 要求做的工作。 +- `diagnostics.mjs` 维护 `activeStage`:当前正在执行的测量阶段名,在 `measure`/`measureSync` 前后设置与恢复,通过 `currentStage()` 暴露。慢请求行把它记为 `stage_isolate`。 + +## 证据 + +| 改前 | 改后 | +| ------------------------------------ | -------------------------------------------------------------------------- | +| 5 条 120 s、内存贴着运行时基线不动 | 请求体读取在 30 秒失败,返回 `408 request_body_timeout`,此时阶段为 `null` | +| 120 s 被杀只能看到内存和最近一次嵌入 | 60 s 自报里写明 isolate 当时所在的阶段 | + +## 这确立了什么、没有确立什么 + +- 它**没有证明**那五条就是停滞的上传。内存不动与之相符,但只是相符:如果是边缘侧的等待根本没进 handler,表现一样,而且会**完全没有** `nmg_request` 行。下一次窗口里,"有阶段字段/没有请求行"正是区分两者的依据。 +- X 组在这里未处理。其机制(嵌入超时、单实例串行)记录在 `2026-10-30-embedding-breaker` 与 `postmortem/0006`;那一侧剩下的杠杆是提供商与评测端并发。 +- `stage_isolate` 在本平台(每请求一个新 isolate)是精确的;若某个 isolate 被复用给并发请求则只是近似——这正是字段名叫 isolate 而不是 request 的原因。 + +## 考虑过的替代方案 + +- **依赖平台的 120 s 掐断**:这正是现状——停滞的 body 把一次请求变成占满预算的孤儿,而且不留信息。 +- **用 `Promise.race` 超时读体**:race 只是把一个还在往死掉的 promise 里灌数据的 reader 留在后台;逐次读前检查并 `cancel()` 才是真正释放连接的写法。 +- **只靠内存推断 X/Y 两组**:内存是指纹不是证据;被杀死在第一次提供商调用里的请求,与等待 body 的请求,差别不止一个数字——所以这次一并加了阶段字段。 + +## 后果 + +- 停滞的上传现在只花 30 秒并返回清晰的 408,不再吃掉整次调用。 +- 日志每条慢请求多一个字符串;普通请求没有变化。 +- 下一个 120 s 窗口可诊断:`stage_isolate` 说明 isolate 在做什么,而缺少 `nmg_request` 行则说明请求根本没到我们这里。 From 6fcd0159964cebd2e3ec86c74d77b857e4bbdc9a Mon Sep 17 00:00:00 2001 From: wefio <48851810+wefio@users.noreply.github.com> Date: Fri, 30 Oct 2026 20:17:41 +0800 Subject: [PATCH 3/3] test(onlion): pin the EdgeOne packaging contract that broke /search in production The 2026-10-30 regression - callbacks.wasm deferred behind a factory, so `new WebAssembly.Instance(fn, ...)` threw and every SQLite-backed /search answered 503 in 75-150 ms - was invisible to `npm run verify`: Miniflare runs the wrangler build, which never executes the inline-wasm plugin. Only the endpoint found it. `test-edgeone-package.mjs` builds the EdgeOne function and asserts the artefact, not the source: - callbacks.wasm is inlined as an eager WebAssembly.Module (the loader imports it as one); - sqlite3.wasm is compiled on first use, so a request that never reads a partition does not decode or compile 1.5 MiB; - every inline base64 payload still compiles to a real module with exports; - the OPFS installers are gated off via globalThis.sqlite3ApiConfig - the only place the loader reads that setting; init()'s argument is silently ignored, which is exactly what the first attempt at that flag did; - the built entry still exports onRequest. Also tried and rejected: a Miniflare test for the stalled-body deadline. workerd buffers the request body before invoking the handler, so the stream never reaches readJson and the test hangs instead of exercising the deadline. The deadline is verified by reading the code path and by one live behaviour (408 within the window); a test that a no-op would pass is worse than no test, and one that hangs the suite is worse still. 38 PASS (verify now includes the packaging test). --- onlion/package.json | 5 +- onlion/scripts/test-edgeone-package.mjs | 89 +++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 onlion/scripts/test-edgeone-package.mjs diff --git a/onlion/package.json b/onlion/package.json index 322dda94..462df8ab 100644 --- a/onlion/package.json +++ b/onlion/package.json @@ -18,7 +18,7 @@ "test:worker-media": "node scripts/test-multimodal-worker.mjs", "test:cos": "node scripts/test-cos-bucket.mjs", "test:edgeone": "node scripts/test-edgeone-worker.mjs", - "verify": "npm run build && npm run check && npm run test:temporal && npm run test:media && npm run test:backend && npm run test:append-log && npm run test:manifest-pending && npm run test:add-log && npm run test:page-delta && npm run test:extractive && npm run test:mem && npm run test:worker-media", + "verify": "npm run build && npm run check && npm run test:temporal && npm run test:media && npm run test:backend && npm run test:append-log && npm run test:manifest-pending && npm run test:add-log && npm run test:page-delta && npm run test:extractive && npm run test:mem && npm run test:worker-media && npm run test:edgeone-package", "generate:callbacks": "node scripts/generate-callbacks.mjs", "smoke:edgeone": "node scripts/smoke-edgeone.mjs", "test:add-log": "node scripts/test-add-log-mode.mjs", @@ -26,7 +26,8 @@ "test:manifest-pending": "node scripts/test-manifest-pending.mjs", "test:page-delta": "node scripts/test-page-delta.mjs", "test:extractive": "node --experimental-transform-types scripts/test-extractive-summaries.mjs", - "test:mem": "node scripts/test-mem-trace.mjs" + "test:mem": "node scripts/test-mem-trace.mjs", + "test:edgeone-package": "node scripts/test-edgeone-package.mjs" }, "devDependencies": { "@types/node": "24.12.4", diff --git a/onlion/scripts/test-edgeone-package.mjs b/onlion/scripts/test-edgeone-package.mjs new file mode 100644 index 00000000..2b390f15 --- /dev/null +++ b/onlion/scripts/test-edgeone-package.mjs @@ -0,0 +1,89 @@ +/* eslint-disable no-console -- deliberate test output */ +/* Pins the EdgeOne packaging contract and the two loader facts that were learned the hard way. + * + * Why this exists: on 2026-10-30 the shipped bundle deferred **both** wasm modules behind a + * factory. `callbacks.wasm` is imported by the generated loader as a real `WebAssembly.Module`, + * so `new WebAssembly.Instance(fn, ...)` threw inside `runSQLite3PostLoadInit` and every `/search` + * that needed SQLite answered 503 in 75-150 ms - while `/add` kept working, because the accept + * never touches SQLite. `npm run verify` could not see it: Miniflare runs the wrangler build, which + * never executes this plugin. The endpoint found it. + * + * So the assertions are about the artefacts this build produces, not about the source: + * 1. `callbacks.wasm` is inlined as an eager `new WebAssembly.Module(...)`; + * 2. `sqlite3.wasm` is inlined behind a factory, so a request that never reads a partition does + * not decode or compile 1.5 MiB; + * 3. both modules actually compile from the bytes in the bundle, which is what the loader does; + * 4. the OPFS VFS installers are gated off through `globalThis.sqlite3ApiConfig`, the only place + * the loader reads that setting (`init()`'s argument is ignored - tried, silently inert). + * + * No network, no Miniflare: esbuild + V8. + */ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import path from "node:path"; +import { execFileSync } from "node:child_process"; + +const root = process.cwd(); +const outDir = path.join(root, "dist/edgeone"); +const bundle = path.join(outDir, "cloud-functions/[[default]].js"); + +console.log("building the EdgeOne function for the packaging assertions…"); +execFileSync(process.execPath, [path.join(root, "scripts/build-edgeone.mjs")], { + cwd: root, + stdio: "inherit", +}); +const source = fs.readFileSync(bundle, "utf8"); + +// 1. callbacks.wasm: eager module, because the generated loader imports it as one. +assert.match( + source, + /var callbacks_default = new WebAssembly\.Module\(Buffer\.from\(/, + "callbacks.wasm must stay an eager WebAssembly.Module: the loader does new WebAssembly.Instance(callbacksModule, ...)", +); +assert.doesNotMatch( + source, + /function compiledWasm\(\).*callbacks/s, + "callbacks.wasm must not be wrapped in the lazy factory", +); + +// 2. sqlite3.wasm: lazy factory, and the bytes are still in the bundle (no filesystem dependency). +assert.match( + source, + /function compiledWasm\d*\(\) \{\s*compiled\d* \?\?= new WebAssembly\.Module\(Buffer\.from\(encodedBytes\d*, "base64"\)\);\s*return compiled\d*;\s*\}/, + "sqlite3.wasm must be compiled on first use, not at module scope", +); + +// 3. Both inline payloads must still be real wasm: compile them the way the bundle does. +const base64Payloads = [...source.matchAll(/Buffer\.from\((?:"([A-Za-z0-9+/=]{200,})"|encodedBytes\d*), "base64"\)/g)] + .map((match) => match[1]) + .filter(Boolean); +assert.ok( + base64Payloads.length >= 1, + "expected at least one inline base64 wasm payload in the bundle", +); +for (const payload of base64Payloads) { + const module = new WebAssembly.Module(Buffer.from(payload, "base64")); + assert.ok( + WebAssembly.Module.exports(module).length > 0, + "an inline wasm payload compiled but exports nothing", + ); +} + +// 4. The OPFS gate must be set where the loader reads it. +assert.match( + source, + /globalThis\.sqlite3ApiConfig\s*=\s*\{/, + "the OpfsVfs installers are gated on globalThis.sqlite3ApiConfig; init()'s argument is ignored", +); +assert.match( + source, + /vfs:\s*\{\s*opfs:\s*true\s*,\s*"opfs-wl":\s*true\s*\}/, + "OPFS and opfs-wl must be disabled, not attempted", +); + +// 5. And the entry still exports the handler the platform scans for. +assert.match(source, /export \{[\s\S]{0,200}onRequest/, "the built entry must export onRequest"); + +console.log( + "PASS: EdgeOne packaging — callbacks.wasm eager, sqlite3.wasm lazy, both wasm payloads compile, OPFS gated off, onRequest exported.", +);