#!/usr/bin/env node /** * verify-mem-model.mjs —— 内存模型「单一事实源」交叉校验(档案 84 → 档案 96 改版) * * 这个脚本守的是一条**曾经漂过、又改过一次设计**的事实链,历史分三段,都留着: * * ① 2026-09-13(档案 84):发现问题 —— 「功能管理」页显示 388 MiB 并报「将超出上限」, * 而平台真实配额是 672/384。根因 = **同一事实被复制成两份**:编排器的 BASE/MIN/MAX/插件表 * 与客户端的 MEM_BASE_MIB/…/MEM_TABLE。⇒ 本脚本把两处钉死。 * * ② 2026-09-13(档案 81 · R1-④):编排器改成「基准 + Σ插件预估」,本脚本随之断言**插件表逐键一致**。 * * ③ **2026-09-14(档案 96,用户要求)**:**与插件开关解耦** —— 最终口径为 * 「**基础 MIN、最多浮动到 MAX**」= cgroup `MemoryHigh=MIN`(软限)+ `MemoryMax=MAX`(硬限)。 * 理由(实测):用「估计表」定「硬上限」时,估偏低就真 OOM —— admin 带 mcn 后峰值 409 MiB 而当时 * 上限 384 ⇒ 一分钟内被 cgroup OOM 杀 4 次、打出 crash 熔断;且配额随开关跳动,界面「预估」与 * 实例真实上限绑死,语义混乱。 * ⇒ 因此本脚本**换向**:不再要求两处都维护插件表(配额只受 MIN/MAX 决定),而是断言 * · 编排器**不得**再用插件集合算配额(表与 BASE 已移除); * · 客户端**保留**插件表,但**只用于预估**(`quotaOf` 不再依赖它); * · MIN / MAX / HEADROOM 两处仍逐值一致。 * * 用法:node scripts/verify-mem-model.mjs 退出码 0=全绿 / 1=有失败 */ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') const read = (p) => readFileSync(join(ROOT, p), 'utf8') let failed = 0 const ok = (name, cond, extra = '') => { console.log((cond ? ' ✓ ' : ' ✗ ') + name + (extra ? ' ' + extra : '')) if (!cond) failed++ } // ── ① 编排器(权威)──────────────────────────────────────── const orch = read('src/supervisor/orchestrator.ts') const spawner = read('src/supervisor/spawner.ts') const dshRoute = read('src/web/routes/dsh.ts') const client = read('poc/business-plugins/lib/client.js') const num = (src, name) => { const m = new RegExp('const ' + name + ' = (\\d+)').exec(src) return m ? Number(m[1]) : null } const ORCH = { min: num(orch, 'MIN_MEM_MB'), max: num(orch, 'MAX_MEM_MB'), headroom: num(orch, 'HEAP_HEADROOM_MB'), } ok('编排器常量齐备(MIN/MAX/HEADROOM)', ORCH.min !== null && ORCH.max !== null && ORCH.headroom !== null, `-> ${JSON.stringify(ORCH)}`) ok('MIN ≤ MAX(配额取 MIN、MAX 作硬顶,顺序不能反)', ORCH.min !== null && ORCH.max !== null && ORCH.min <= ORCH.max, `-> MIN ${ORCH.min} / MAX ${ORCH.max}`) // ── ② 反回退:编排器的配额**不得**再依赖插件集合(档案 96 的核心)──────── ok('编排器已移除插件成本表 PLUGIN_MEM_MB', !/PLUGIN_MEM_MB/.test(orch)) ok('编排器已移除 BASE_MEM_MB(配额不再 = 基准 + Σ插件)', !/BASE_MEM_MB/.test(orch)) // ⚠️ 只看 `instanceMemMb` 的**函数体**:编排器别处仍会读 bundles(例如「从 bundles 摘掉某插件」, // 与配额无关)——用全文件匹配会误报。 const memFns = [/function instanceBaseMb\(\)[\s\S]*?\n\}/.exec(orch)?.[0] ?? '', /function instanceMaxMb\(\)[\s\S]*?\n\}/.exec(orch)?.[0] ?? ''].join('\n') ok('两个访问器都不读 profile/bundles', memFns.trim() !== '' && !/bundles|package\.json|readFileSync/.test(memFns), '-> ' + memFns.replace(/\s+/g, ' ').slice(0, 110)) ok('编排器有 instanceBaseMb() / instanceMaxMb() 两个访问器', /function instanceBaseMb\(\): number/.test(orch) && /function instanceMaxMb\(\): number/.test(orch)) ok('cgroup 给两个参数:MemoryHigh=基础 / MemoryMax=上界', /'MemoryHigh=' \+ instanceBaseMb\(\) \+ 'M'/.test(orch) && /'MemoryMax=' \+ memMb \+ 'M'/.test(orch)) ok('调用点已不传 profile 路径(3 处 spawn/repair + quotaInfo)', (orch.match(/instanceMaxMb\(\)/g) ?? []).length >= 3, `-> ${(orch.match(/instanceMaxMb\(\)/g) ?? []).length} 处`) // ── ③ 客户端:常量仍与编排器一致,但插件表只准用于预估 ────────────────── const cnum = (src, name) => { const m = new RegExp('var ' + name + ' = (\\d+)').exec(src) return m ? Number(m[1]) : null } const CLIENT = { min: cnum(client, 'MEM_MIN_MIB'), max: cnum(client, 'MEM_MAX_MIB'), headroom: cnum(client, 'MEM_HEAP_HEADROOM_MIB'), } for (const [label, a, b] of [['下限 MIN', ORCH.min, CLIENT.min], ['硬顶 MAX', ORCH.max, CLIENT.max], ['堆预留 HEADROOM', ORCH.headroom, CLIENT.headroom]]) { ok(`常量一致 · ${label}`, a === b, `-> 编排器 ${a} / 客户端 ${b}`) } ok('客户端 quotaOf 兜底值 = 硬顶上界 MEM_MAX_MIB', /function quotaOf\(_raw\)\s*\{\s*return MEM_MAX_MIB/.test(client)) ok('客户端显示「基础 → 最多」(i18n mem.base 存在)', /"mem\.base"\s*:/.test(client) && /quotaInfo\.baseMb/.test(client)) ok('客户端**保留**插件表 MEM_TABLE(给用户看的预估)', /var MEM_TABLE = \{/.test(client)) ok('客户端保留预估算式 rawMiB()(基线 + Σ插件)', /function rawMiB\(list, key\)/.test(client)) ok('预估用的基线常量仍在(MEM_BASE_MIB)', cnum(client, 'MEM_BASE_MIB') !== null) ok('客户端注释标明插件表「仅用于预估」', /仅用于预估/.test(client)) // ── ④ heap 推导两侧同构 ───────────────────────────────── ok('客户端 heap 推导与编排器同构', /Math\.max\(128, Math\.min\(256, memMb - MEM_HEAP_HEADROOM_MIB\)\)/.test(client)) ok('编排器 heap 推导算法未变', /Math\.max\(128, Math\.min\(256, memMb - HEAP_HEADROOM_MB\)\)/.test(orch)) // ── ⑤ status 真的把真值透出来了(否则前端「读真值」是空接线)── ok('Spawner 接口声明 quotaInfo?', /quotaInfo\?\(userId: string\): { baseMb: number; memMb: number; heapMb: number } \| null/.test(spawner)) ok('编排器实现 quotaInfo', /quotaInfo\(userId: string\): { baseMb: number; memMb: number; heapMb: number } \| null \{/.test(orch)) ok('/api/dsh/status 返回 quota', /quota:\s*app\.supervisor\.quotaInfo\?\.\([^)]*\)\s*\?\?\s*null/.test(dshRoute)) ok('客户端会去读 /api/dsh/status(显示真配额)', /\/api\/dsh\/status/.test(client) && /setQuotaInfo\(/.test(client)) console.log(failed === 0 ? '\n结论:全绿 ✅' : '\n结论:有 ' + failed + ' 项失败 ❌') process.exit(failed === 0 ? 0 : 1)