From f6d4ad9b153c3243fd618c86cf7d9f1207f8bf39 Mon Sep 17 00:00:00 2001 From: maogeigei Date: Sun, 13 Sep 2026 21:38:04 +0800 Subject: [PATCH] =?UTF-8?q?fix(mem):=20=E7=BB=9F=E4=B8=80=E5=AE=9E?= =?UTF-8?q?=E4=BE=8B=E5=86=85=E5=AD=98=E9=85=8D=E9=A2=9D=E5=8F=A3=E5=BE=84?= =?UTF-8?q?=20+=20status=20=E6=9A=B4=E9=9C=B2=E7=9C=9F=E5=80=BC=EF=BC=88?= =?UTF-8?q?=E6=B6=88=E7=81=AD=E3=80=8C388=20MiB=E3=80=8D=E8=AF=AF=E6=8A=A5?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户问「实例内存大小是不是调整过了,为什么功能设置中还是显示 388 多」。 实测:guest 真实 MemoryMax = 672 MiB、admin = 384 MiB(都真调整过); 而「功能管理」显示的是插件**自建的第二套模型**(基线 285 / univer 64 / mcn 39, 且把 clamp 下限 384 当硬上限)⇒ 全勾显示 388 并报「⚠ 将超出上限」,纯误报。 根因:档案 81 R1 把「写死 384」改为「按插件集合推导」时**只改了编排器**, 插件那份副本原地漂移,此后 univer 384→512、mcn-suite 128 都没同步。 平台侧(把已算好的真值透出来,零新增计算): - spawner.ts 新增可选 quotaInfo?(userId)(照 breakerInfo 模式) - orchestrator.ts 实现 quotaInfo(),走**同一个** instanceMemMb()/heapMbFor() - /api/dsh/status 返回 quota: {memMb, heapMb} 防漂(根因是「同一事实两处副本」,必须让它不可能再漂): - 新增 scripts/verify-mem-model.mjs,逐项交叉断言常量/成本表/clamp 算式/接线点, 接进 npm run verify —— 任一处漂移即构建失败(已做反向验证:改回 285 即 exit 1) 验证:build 零报错;verify 全绿(含新增 15 项断言);服务端 md5 与本机编译产物一致; GET /api/dsh/status → quota {memMb:384,heapMb:256} 与实测 402653184 吻合。 注:poc/business-plugins/**(0.3.6)与其 package.json 未在此提交 —— 该文件被并行会话 连续改过两轮(0.3.4/0.3.5,0.3.5 已上线但未 commit),刻意留给他们一起提交。 --- package.json | 2 +- scripts/verify-mem-model.mjs | 98 ++++++++++++++++++++++++++++++++++ src/supervisor/orchestrator.ts | 19 +++++++ src/supervisor/spawner.ts | 7 +++ src/web/routes/dsh.ts | 3 ++ 5 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 scripts/verify-mem-model.mjs diff --git a/package.json b/package.json index 8f7225a..ec1eb14 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "prepare": "npm run build", "dev": "node lib/cli.js", "typecheck": "tsc -p tsconfig.json --noEmit", - "verify": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs", + "verify": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs", "test": "npm run build && node --test test/db.test.mjs test/k8s-spawner.test.mjs test/leader.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js", "smoke": "node scripts/smoke.mjs", "smoke:admin": "node scripts/smoke-admin.mjs", diff --git a/scripts/verify-mem-model.mjs b/scripts/verify-mem-model.mjs new file mode 100644 index 0000000..a805303 --- /dev/null +++ b/scripts/verify-mem-model.mjs @@ -0,0 +1,98 @@ +#!/usr/bin/env node +/** + * verify-mem-model.mjs —— 内存模型「单一事实源」交叉校验(档案 84) + * + * 为什么需要:2026-09-13 实测发现「功能管理」那条内存条显示 388 MiB 并报「将超出上限」, + * 而平台真实配额是 672 / 384 —— 根因是**同一个事实被复制成了两份**: + * · 编排器 `src/supervisor/orchestrator.ts` 的 PLUGIN_MEM_MB / BASE / MIN / MAX(决定 MemoryMax) + * · 客户端 `poc/business-plugins/lib/client.js` 的 MEM_TABLE / MEM_BASE_MIB / …(决定界面显示) + * R1 把「写死 384」改成「按插件集合推导」时只改了前者 ⇒ 后者原地漂了。 + * + * 本脚本把两处**钉死在一起**:任一常量或表项对不上,`npm run verify` 直接失败。 + * 用法: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 num = (src, name) => { + const m = new RegExp('const ' + name + ' = (\\d+)').exec(src) + return m ? Number(m[1]) : null +} +const ORCH = { + base: num(orch, 'BASE_MEM_MB'), + min: num(orch, 'MIN_MEM_MB'), + max: num(orch, 'MAX_MEM_MB'), + headroom: num(orch, 'HEAP_HEADROOM_MB'), +} +const orchTable = {} +{ + const block = /const PLUGIN_MEM_MB: Record = \{([\s\S]*?)\n\}/.exec(orch) + if (block) for (const m of block[1].matchAll(/'([^']+)':\s*(\d+)/g)) orchTable[m[1]] = Number(m[2]) +} + +ok('编排器常量齐备(BASE/MIN/MAX/HEADROOM)', ORCH.base !== null && ORCH.min !== null && ORCH.max !== null && ORCH.headroom !== null, + `-> ${JSON.stringify(ORCH)}`) +ok('编排器插件成本表非空', Object.keys(orchTable).length > 0, '-> ' + JSON.stringify(orchTable)) + +// ── ② 客户端(副本)──────────────────────────────────────── +const client = read('poc/business-plugins/lib/client.js') +const cnum = (src, name) => { + const m = new RegExp('var ' + name + ' = (\\d+)').exec(src) + return m ? Number(m[1]) : null +} +const CLIENT = { + base: cnum(client, 'MEM_BASE_MIB'), + min: cnum(client, 'MEM_MIN_MIB'), + max: cnum(client, 'MEM_MAX_MIB'), + headroom: cnum(client, 'MEM_HEAP_HEADROOM_MIB'), +} +const clientTable = {} +{ + const block = /var MEM_TABLE = \{([\s\S]*?)\}/.exec(client) + if (block) for (const m of block[1].matchAll(/"([^"]+)":\s*(\d+)/g)) clientTable[m[1]] = Number(m[2]) +} + +// ── ③ 断言:两处必须逐项一致 ──────────────────────────────── +const pairs = [ + ['基线 BASE', ORCH.base, CLIENT.base], + ['下限 MIN', ORCH.min, CLIENT.min], + ['硬顶 MAX', ORCH.max, CLIENT.max], + ['堆预留 HEADROOM', ORCH.headroom, CLIENT.headroom], +] +for (const [label, a, b] of pairs) ok(`常量一致 · ${label}`, a === b, `-> 编排器 ${a} / 客户端 ${b}`) + +const allKeys = [...new Set([...Object.keys(orchTable), ...Object.keys(clientTable)])].sort() +const bad = allKeys.filter((k) => orchTable[k] !== clientTable[k]) +ok('插件成本表逐键一致', bad.length === 0, + bad.length === 0 ? `-> ${allKeys.length} 项:${allKeys.map((k) => k + '=' + orchTable[k]).join(', ')}` + : '-> 不一致:' + bad.map((k) => `${k}(编排器 ${orchTable[k]} / 客户端 ${clientTable[k]})`).join('; ')) + +// ── ④ 断言:clamp 算式同款(防「常量对了、规则又各写一套」)──── +ok('编排器 clamp 规则在位', /Math\.min\(Math\.max\(mb, MIN_MEM_MB\), MAX_MEM_MB\)/.test(orch)) +ok('客户端 clamp 规则与编排器同构', /Math\.min\(Math\.max\(raw, MEM_MIN_MIB\), MEM_MAX_MIB\)/.test(client)) +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\): \{ memMb: number; heapMb: number \} \| null/.test(spawner)) +ok('编排器实现 quotaInfo', /quotaInfo\(userId: string\): \{ memMb: number; heapMb: number \} \| null \{/.test(orch)) +ok('/api/dsh/status 返回 quota', /quota: app\.supervisor\.quotaInfo\?\.\(request\.user!\.id\) \?\? 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) diff --git a/src/supervisor/orchestrator.ts b/src/supervisor/orchestrator.ts index 2a32015..02b0356 100644 --- a/src/supervisor/orchestrator.ts +++ b/src/supervisor/orchestrator.ts @@ -918,6 +918,25 @@ export class LocalSpawner implements Spawner { return { opens: b.opens, openedAt: b.openedAt, cooldownUntil: breakerUntil(b, this.breakerPolicy()) } } + /** + * 档案 84 观测面:把「本实例实际拿到的内存配额」透出来 —— 与 spawn 时用的是**同一个** + * `instanceMemMb()` / `heapMbFor()`,因此不可能与实际 `MemoryMax` 对不上。 + * + * 为什么需要它:实例内的客户端插件(`business-plugins` 的「功能管理」)看不到 cgroup, + * 过去只能自建一套估算表 —— 而第二份副本**必然漂**(2026-09-13 实测:插件显示 388 MiB + * 并报「⚠ 将超出上限」,而平台真实配额是 672 / 384,且 384 早已只是**下限**)。 + * 与其让第二份副本再漂一次,不如把已有值暴露出去、让前端读真值。 + */ + quotaInfo(userId: string): { memMb: number; heapMb: number } | null { + try { + const profileDir = join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web') + const memMb = instanceMemMb(profileDir) + return { memMb, heapMb: heapMbFor(memMb) } + } catch { + return null + } + } + /** 清空某用户的崩溃计数(显式启动 / 人工重启 / 停机时调用)。 */ private resetCrashState(userId: string): void { this.crashHistory.delete(userId) diff --git a/src/supervisor/spawner.ts b/src/supervisor/spawner.ts index 16933d8..0cac01d 100644 --- a/src/supervisor/spawner.ts +++ b/src/supervisor/spawner.ts @@ -112,6 +112,13 @@ export interface Spawner { */ breakerInfo?(userId: string): { opens: number; openedAt: number; cooldownUntil: number } | null + /** + * 档案 84:实例内存配额观测面(可选 —— 「配额随插件集合推导」是**本地模式**概念)。 + * 返回 `instanceMemMb()` / `heapMbFor()` 的**同源结果**,供实例内的「功能管理」 + * 直接显示真值,而不是各自维护一份必然会漂的估算表。 + */ + quotaInfo?(userId: string): { memMb: number; heapMb: number } | null + /** Restart every running main so a swapped global API key takes effect (env is a spawn-time snapshot). */ restartAllMains(): Promise spawnWatchdog(userId: string): Promise diff --git a/src/web/routes/dsh.ts b/src/web/routes/dsh.ts index 665aea7..61ddb76 100644 --- a/src/web/routes/dsh.ts +++ b/src/web/routes/dsh.ts @@ -176,6 +176,9 @@ export const dshRoutes: FastifyPluginAsync = async (app) => { watchdog: watchdog ? { id: watchdog.id, status: watchdog.status, exitCode: watchdog.exitCode } : null, // 观测面(档案 78):熔断状态 —— 非 null 即"该用户正被冷却",供门户/排查直接看到 breaker: app.supervisor.breakerInfo?.(request.user!.id) ?? null, + // 观测面(档案 84):本实例**真实**内存配额(= instanceMemMb() 的结果,与 spawn 同源)。 + // 实例内「功能管理」读它显示真值;读不到(老平台/ k8s 模式)时前端才退回保守估算。 + quota: app.supervisor.quotaInfo?.(request.user!.id) ?? null, url: dshUrl(app.config.baseDomain, request.user!, main?.launchToken), } })