fix(mem): 实例内存改为「基础 MIN → 最多 MAX」,与插件开关解耦(档案 96)

用户原话两段:①「改为 实例内存不要受插件开关影响,只受 min 和 max 值影响」
②「改成基础 min 最大可以浮动到 max」。

实现(cgroup 两个参数表达这个区间):
· src/supervisor/orchestrator.ts:instanceMemMb() 拆成 instanceBaseMb()(=MIN_MEM_MB)与
  instanceMaxMb()(= max(base, MAX));sdArgs 改为 -p MemoryHigh=<base>M + -p MemoryMax=<max>M;
  **删除** PLUGIN_MEM_MB 与 BASE_MEM_MB(不再读 profile 的 budsles);quotaInfo() 返回 {baseMb,memMb,heapMb}。
· src/supervisor/spawner.ts:quotaInfo?() 返回类型同步加 baseMb。
· poc/business-plugins/lib/client.js:quotaOf() 兜底值改为硬顶上界(进度条 100% 基准与「超限」判据同它);
  事实行改为「基础 448 MiB → 最多 1024 MiB · V8 堆 256」(新增 i18n mem.base);插件表保留但**仅用于预估**。
· scripts/verify-mem-model.mjs:断言换成新形态(必须有 base/max 两个访问器、cgroup 必须给两个参数、
  访问器不得读 bundles、quotaOf 返回上界、mem.base 存在)。
· 取值沿用用户裁定值:MIN 448(base)/ MAX 1024 —— **我没有自行改数**。

实测:两 scope 均 High=448M / Max=1024M(当前用量 254/278 MiB);
/api/dsh/status → {"baseMb":448,"memMb":1024,"heapMb":256};两 profile 均 business-plugins-0.3.19.tgz。
本机 npm run verify EXIT=0;服务器 ci.sh CI OK。

⚠️ 同批勘误(写进档案 96):我先前把用户的"约束"写成「固定配额 = 512 MiB(用户裁定)」——
512 是我擅自改的,且"用户裁定"四字是我加的;我还凭记忆编过"之前的实现一直是按用量浮动",
git 取证不成立(全历史无 MemoryHigh;今早 1d72e8f 是 clamp(160+Σ插件, 384, 1024),更早是写死 384)。

⚠️ 未提交:BRIEF.md / DEPLOY-本部署.md 的配额口径同步(那两个文件本就有别人未提交的改动)。
This commit is contained in:
admin committed 2026-09-14 22:26:06 +08:00
1 parent e18eaa2b64
commit 53b8eff870
5 files changed
+119 -114

No files matched your search

+25 -17
View File
@@ -85,7 +85,8 @@ window.__ModuleLoader__.load({
// ── 内存模型(档案 84;口径**与平台编排器同源**)────────────────────────── // ── 内存模型(档案 84;口径**与平台编排器同源**)──────────────────────────
// //
// 权威定义在 `src/supervisor/orchestrator.ts`: // 权威定义在 `src/supervisor/orchestrator.ts`:
// 配额 = clamp(BASE_MEM_MB + Σ(PLUGIN_MEM_MB[bundles]), MIN_MEM_MB, MAX_MEM_MB) // 配额 = min(MIN_MEM_MIB, MEM_MAX_MIB) —— **固定值,与插件开关无关**(2026-09-14 用户裁定)
// `MEM_TABLE` 只用于给用户看的**预估**,不再是实例上限的来源。
// 下面的常量**逐键与它对齐**,并由 `scripts/verify-mem-model.mjs` 在 `npm run verify` // 下面的常量**逐键与它对齐**,并由 `scripts/verify-mem-model.mjs` 在 `npm run verify`
// 里交叉断言 —— 两处一旦不同步,构建直接失败。 // 里交叉断言 —— 两处一旦不同步,构建直接失败。
// //
@@ -94,14 +95,16 @@ window.__ModuleLoader__.load({
// 而平台真实配额是 672(guest)/ 384(admin),**384 早已只是下限** ⇒ 纯属误报。 // 而平台真实配额是 672(guest)/ 384(admin),**384 早已只是下限** ⇒ 纯属误报。
// 修法:① 常量与规则对齐平台;② 优先读 `/api/dsh/status` 的 `quota.memMb/heapMb` // 修法:① 常量与规则对齐平台;② 优先读 `/api/dsh/status` 的 `quota.memMb/heapMb`
// (平台**实际使用**的值)直接显示;③「超限」改为判断是否顶到硬顶 MAX。 // (平台**实际使用**的值)直接显示;③「超限」改为判断是否顶到硬顶 MAX。
// 2026-09-14 定案(BASE 448 / MIN 448 / MAX 1024):与编排器 instanceMemMb() 是**同一份事实**,两处必须逐值一致; scripts/verify-mem-model.mjs 会逐键比对。 // 2026-09-14 口径(用户要求):**基础 MIN、最多浮动到 MAX,与插件开关无关**;
/** 与编排器 `BASE_MEM_MB` 一致。0.1.5 基座实测 312 MiB ⇒ 抬到 448(旧值 160 按 0.1.2 的 106~145,严重低估)。 */ // 平台侧 = cgroup 两个参数(`MemoryHigh=MIN` 软限 / `MemoryMax=MAX` 硬限);
// 与编排器仍是同一份事实(MIN/MAX/HEADROOM 逐值一致,verify-mem-model 交叉断言)。
/** **仅用于预估**(0.1.5 基座实测 312 MiB):不再参与实例配额计算。 */
var MEM_BASE_MIB = 448; var MEM_BASE_MIB = 448;
/** 与编排器 `MIN_MEM_MB` 一致 —— ⚠️ 这是**下限**,不是上限;**必须 ≥ BASE**,否则下限失去意义。 */ /** 与编排器 `MIN_MEM_MB` 一致 —— **基础值**(cgroup `MemoryHigh` 软限;稳态落在这附近)。 */
var MEM_MIN_MIB = 448; var MEM_MIN_MIB = 448;
/** 与编排器 `MAX_MEM_MB` 一致 —— 单实例硬顶。 */ /** 与编排器 `MAX_MEM_MB` 一致 —— **上浮上界**(cgroup `MemoryMax` 硬限;越界 OOM)。 */
var MEM_MAX_MIB = 1024; var MEM_MAX_MIB = 1024;
/** 与编排器 `PLUGIN_MEM_MB` 一致;未识别的插件平台按 0 计。 */ /** **仅用于预估**:逐插件加载成本(未识别的按 0 计)。⚠️ 平台不再据此调整配额。 */
var MEM_TABLE = { var MEM_TABLE = {
"dsh-univer-office": 512, "dsh-univer-office": 512,
"dsh-plugin-mcn-suite": 128 "dsh-plugin-mcn-suite": 128
@@ -128,9 +131,11 @@ window.__ModuleLoader__.load({
return total; return total;
} }
/** 按平台同款规则把原始需求折算成实际配额(clamp 到 [MIN, MAX])。 */ /** 实例内存的**上浮上界**(MiB)= cgroup 硬限;进度条 100% 基准与「超限」判据都用它。
function quotaOf(raw) { * ⚠️ 平台真值优先取 `/api/dsh/status` 的 `quota.memMb`;本函数只是读不到时的兜底。
return Math.min(Math.max(raw, MEM_MIN_MIB), MEM_MAX_MIB); * 形参 `_raw` 保留只为调用处可读 —— 预估另由 `rawMiB()` 单独算。 */
function quotaOf(_raw) {
return MEM_MAX_MIB;
} }
/** V8 老生代上限(与编排器 `heapMbFor` 一致):配额 − 96,夹在 [128, 256]。 */ /** V8 老生代上限(与编排器 `heapMbFor` 一致):配额 − 96,夹在 [128, 256]。 */
@@ -176,6 +181,7 @@ window.__ModuleLoader__.load({
"loadFailed": "加载失败", "loadFailed": "加载失败",
"mem.title": "内存配额", "mem.title": "内存配额",
"mem.est": "估算值(未读到平台配额,按平台同款算式推导)", "mem.est": "估算值(未读到平台配额,按平台同款算式推导)",
"mem.base": "基础",
"mem.real": "实际配额", "mem.real": "实际配额",
"mem.heap": "V8 堆", "mem.heap": "V8 堆",
"mem.now": "当前", "mem.now": "当前",
@@ -185,7 +191,7 @@ window.__ModuleLoader__.load({
"mem.over": "顶到硬顶", "mem.over": "顶到硬顶",
"mem.tight": "接近硬顶", "mem.tight": "接近硬顶",
"mem.safe": "余量充足", "mem.safe": "余量充足",
"mem.overWarn": "勾选后的内存需求已顶到单实例硬顶(1024 MiB),配额不会再增加,实例可能起不来。建议先停用部分插件再应用。", "mem.overWarn": "本插件的内存**预估**已超过实例配额(配额固定,不会因为多装插件而增加),实例可能起不来。建议先停用部分插件再应用。",
"mem.perCard": "预估内存", "mem.perCard": "预估内存",
// ── 「系统管理」分区(第 3 轮 · 2026-09-13)────────────────────────────── // ── 「系统管理」分区(第 3 轮 · 2026-09-13)──────────────────────────────
// 用户要求:弹窗里的**功能名称 / 表格 / 功能**必须与门户 `web/portal.html` 一致。 // 用户要求:弹窗里的**功能名称 / 表格 / 功能**必须与门户 `web/portal.html` 一致。
@@ -520,6 +526,7 @@ window.__ModuleLoader__.load({
"loadFailed": "Failed to load", "loadFailed": "Failed to load",
"mem.title": "Memory quota", "mem.title": "Memory quota",
"mem.est": "estimate (platform quota unavailable; same formula as the orchestrator)", "mem.est": "estimate (platform quota unavailable; same formula as the orchestrator)",
"mem.base": "base",
"mem.real": "actual quota", "mem.real": "actual quota",
"mem.heap": "V8 heap", "mem.heap": "V8 heap",
"mem.now": "now", "mem.now": "now",
@@ -529,7 +536,7 @@ window.__ModuleLoader__.load({
"mem.over": "hit the hard cap", "mem.over": "hit the hard cap",
"mem.tight": "close to the hard cap", "mem.tight": "close to the hard cap",
"mem.safe": "headroom is fine", "mem.safe": "headroom is fine",
"mem.overWarn": "The selected plugins need more than the per-instance hard cap (1024 MiB), so the quota will not grow further and the instance may fail to start. Disable some plugins first.", "mem.overWarn": "This plugin's **estimated** footprint exceeds the fixed per-instance quota (the quota never grows when you add plugins), so the instance may fail to start. Disable some plugins first.",
"mem.perCard": "load cost", "mem.perCard": "load cost",
"pa.label": "System management", "pa.label": "System management",
"pa.homeSub": "The same feature set as the portal — open any item to manage it in a popup.", "pa.homeSub": "The same feature set as the portal — open any item to manage it in a popup.",
@@ -1212,8 +1219,8 @@ window.__ModuleLoader__.load({
* 带来的增量;超过单实例上限时整条转红并给出文字警告。全程只读本地数据、不发请求。 * 带来的增量;超过单实例上限时整条转红并给出文字警告。全程只读本地数据、不发请求。
*/ */
function MemBar(props) { function MemBar(props) {
// 进度条的 100% 基准 = **硬顶**(`MEM_MAX_MIB`)。 // 进度条的 100% 基准 = **实际配额**(= `MEM_MIN_MIB`,固定值)。
// ⚠️ 不能拿「配额」当基准:配额本身是 clamp 出来的(≥384),当基准会让所有实例都满格。 // 2026-09-14 起配额不再随插件集合增长 ⇒ 拿配额当基准才反映「用了多少比例」。
var limit = props.limit; var limit = props.limit;
var quotaNow = props.quotaNow; // 当前实际配额(优先 status 真值) var quotaNow = props.quotaNow; // 当前实际配额(优先 status 真值)
var quotaPlanned = props.quotaPlanned; // 勾选后的配额 var quotaPlanned = props.quotaPlanned; // 勾选后的配额
@@ -1335,8 +1342,9 @@ window.__ModuleLoader__.load({
var quotaPlanned = quotaOf(rawPlanned); var quotaPlanned = quotaOf(rawPlanned);
var realHeapMb = (quotaInfo && quotaInfo.heapMb != null) ? quotaInfo.heapMb : heapMiBFor(quotaNow); var realHeapMb = (quotaInfo && quotaInfo.heapMb != null) ? quotaInfo.heapMb : heapMiBFor(quotaNow);
// 事实行文案:优先展示平台**实际下发**的配额与堆;读不到才如实标注为估算。 // 事实行文案:优先展示平台**实际下发**的配额与堆;读不到才如实标注为估算。
var baseMb = (quotaInfo && quotaInfo.baseMb != null) ? quotaInfo.baseMb : MEM_MIN_MIB;
var memFactText = (quotaInfo && quotaInfo.memMb != null) var memFactText = (quotaInfo && quotaInfo.memMb != null)
? (t("mem.real") + " " + quotaInfo.memMb + " MiB · " + t("mem.heap") + " " + realHeapMb + " MiB") ? (t("mem.base") + " " + baseMb + " MiB → " + t("mem.limit") + " " + quotaInfo.memMb + " MiB · " + t("mem.heap") + " " + realHeapMb + " MiB")
: t("mem.est"); : t("mem.est");
var inputStyle = { var inputStyle = {
@@ -1362,7 +1370,7 @@ window.__ModuleLoader__.load({
// 当前配额 / 勾选后配额 / 是否顶到硬顶,让用户在点「应用」之前就知道后果。 // 当前配额 / 勾选后配额 / 是否顶到硬顶,让用户在点「应用」之前就知道后果。
rows.push(jsxRuntime.jsx(MemBar, { rows.push(jsxRuntime.jsx(MemBar, {
key: "__membar", key: "__membar",
limit: MEM_MAX_MIB, limit: quotaOf(),
quotaNow: quotaNow, quotaNow: quotaNow,
quotaPlanned: quotaPlanned, quotaPlanned: quotaPlanned,
rawNow: rawNow, rawNow: rawNow,
@@ -1577,7 +1585,7 @@ window.__ModuleLoader__.load({
// 内存块(**超限时红底 + 警告**,用户 2026-09-13 明确要求)→ 按钮行(取消 / 确认)。 // 内存块(**超限时红底 + 警告**,用户 2026-09-13 明确要求)→ 按钮行(取消 / 确认)。
// 点遮罩关闭、点面板 stopPropagation(与 MCN 同款交互)。 // 点遮罩关闭、点面板 stopPropagation(与 MCN 同款交互)。
if (confirmOpen) { if (confirmOpen) {
var overNow = rawPlanned > MEM_MAX_MIB; var overNow = rawPlanned > quotaOf();
rows.push(jsxRuntime.jsxs("div", { rows.push(jsxRuntime.jsxs("div", {
key: "__confirm", key: "__confirm",
style: { style: {
@@ -1640,7 +1648,7 @@ window.__ModuleLoader__.load({
key: "n", key: "n",
style: { fontSize: "13px", color: T.sub, fontVariantNumeric: "tabular-nums" }, style: { fontSize: "13px", color: T.sub, fontVariantNumeric: "tabular-nums" },
children: t("mem.now") + " " + quotaNow + " MiB → " + t("mem.planned") + " " + quotaPlanned children: t("mem.now") + " " + quotaNow + " MiB → " + t("mem.planned") + " " + quotaPlanned
+ " MiB / " + t("mem.limit") + " " + MEM_MAX_MIB + " MiB" + " MiB / " + t("mem.limit") + " " + quotaOf() + " MiB"
}), }),
overNow overNow
? jsxRuntime.jsx("div", { ? jsxRuntime.jsx("div", {
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "@dsh-local/business-plugins", "name": "@dsh-local/business-plugins",
"version": "0.3.17", "version": "0.3.19",
"description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.17(2026-09-14):**内存口径定案**(BASE 448 / MIN 448 / MAX 1024,插件成本表 univer 512、mcn 128)—— 与编排器 `instanceMemMb()` 逐值对齐(`verify-mem-model.mjs` 会红着提醒)。① BASE 160→448:0.1.5 基座**实测 312 MiB**,旧值按 0.1.2 的 106~145 估的,严重低估 ⇒ 384 的上限里只剩 72 MiB 给插件,这正是宿主 swap 被吃掉的原因;② **MIN 必须 ≥ BASE**(否则 `clamp(raw, MIN, MAX)` 的下限失去意义)⇒ MIN 384→448;③ **MAX 保持 1024**(不是偏好:编排器头部已写「上限 1024M 是单实例硬顶(宿主 1870M)」这一不变量,在途草稿的 1536 与宿主容量前提自相矛盾,已驳回);④ mcn 套件**保持 128**:无可支撑抬高的实测,反向有证据(装了它的用户在旧 384 上限下一直跑得住 ⇒ 需求 ≤384),而抬高会直接吃宿主余量(1870M / 可用 ≈761M / swap 已用 452M)。|v0.3.16(2026-09-14):**官方推荐插件列表按 dsh 版本过滤**(用户:「插件管理中的官方推荐插件列表 只能显示匹配当前dsh版本 和 超过当前版本的插件(超过的要明确标注)」)。① 官方目录(`awesome-dsh-plugin.com/plugins.json`)**不带 dsh 版本字段** ⇒ 平台侧逐包查 npm 精简 packument 的 `peerDependencies / engines` 与**平台真实版本**比对,分类 `match / none / newer / older / unknown`(判定在 `src/web/plugin-dsh-compat.ts`);② **默认隐藏 `older`**(只声明了更旧版本),`newer`(要求更高的 dsh)**橙色显著标注**并在 tooltip 给出「需 ≥ X,当前 Y」;③ 说明行给出「已按 dsh X 过滤,隐藏 N 个…」且**勾选框「含仅兼容更旧版本的」是显式逃生口** —— 绝不让条目静默消失;④ 判定结果按 `<npm>@<版本>` 落盘缓存 7 天,首次进页后台预热全量,请求内只给 6s 预算、超预算判 `unknown` 并**照常显示**(网络抖动不该等同「不兼容」);⑤ ⚠️ 语义:判 `satisfies` **必须带 `includePrerelease: true`** —— 平台版本是 prerelease,默认语义下 `^0.1.2` 甚至 `*` 都不满足,实测 TOP300 会把 `dsh-univer-office`/`dshmarket` 这类**在跑的**插件误判为 older(`match 97/older 139` → 容忍后 `match 214/older 22`);且**伞包 `@deepseek-ai/dsh` 必须单独解析**(它不在自己的 node_modules 里,而那 3 条 `newer` 全部只写在伞包上)。|v0.3.15(2026-09-14):**删除条目补二次确认**(官方 `deleteDialog` 的等价物)。原「删除」紧挨着「停用」且**无任何确认** ⇒ 一次误点就连已存储的 API 密钥一起删掉(`DELETE /api/me/keys/:id` 不可撤销,用户只能重新粘贴密钥);现改为弹窗**点名该提供方**(`ms.delTitle` / `ms.delDesc` / `ms.delConfirm`)后才执行,遮罩点击或「取消」可关。|v0.3.14(2026-09-14):**「模型设置」页按官方 `dsh-client-ui-settings-models` 复刻交互**(用户:「admin模型设置和状态 一个名称换行了,另一个两个按钮换行了,交互体验需要优化…新增模型的操作交互和官方原始新增模型的交互差距很大,建议仔细参考 复刻官方的交互」)。① **换行根因**:原「我已添加的厂家」是 4 列表格(`pa-tbl` 自动列宽)⇒「内置 DeepSeek」与「停用/删除」被挤到换行;改为**官方卡片行**(`ms-rowCard`/`ms-rowHead`/`ms-rowIdentity`/`ms-rowActions`,名称与按钮两侧均 `nowrap`)⇒ **结构上不可能换行**,状态文字下沉到独占一行的副标题。② **新增流程改为官方的两步式**:默认只露**两个虚线按钮**(「+ 添加提供方」/「+ 添加自定义提供方」,`flex:1 1 0;min-width:180px;height:44px`),点击才展开卡片 —— 旧版是「常开表单 + 厂家下拉里混一项『自定义厂家…』」。③ 新增卡片**主字段是单独一个「API 密钥」输入框**(官方原文:页面从不问环境变量名),显示名挪进收起的「自定义设置」`<details>`;自定义卡片按官方字段序(Provider ID → 显示名称 → API 地址 → API 协议 → API 密钥 → 模型目录),模型目录由**一行一个的文本框**改成官方的**行式编辑器**(`+ 添加模型` / 行尾 ✕ 删除)。④ CSS 数值**逐值照抄**官方 CSS 模块(圆角 16/18/14/12/10/8/6/4、字号 14/12/11、行高 22/18/16、按钮高 36/28/44px),颜色走同一批 `--dsw-*` token。⑤ 顺带修两处文案缺陷:admin 自己看共享模型卡片时原写「由 admin 配置」(后端早已给 `ownerIsMe`)⇒ 改「由你配置」;「内置 DeepSeek 密钥留空则回落平台共享密钥」是**错的**(`keyAddSchema` 里 `apiKey` 是 `required + minLength:1`,留空必得 400)⇒ 改为「输Line truncated "description": "功能管理(原「功能插件」)section for dsh web profile — v0.3.19(2026-09-14):**内存口径最终版 —— 基础 MIN、最多浮动到 MAX**(用户:「改成基础 min 最大可以浮动到 max」)。平台侧 = cgroup **两个参数**:`MemoryHigh=MIN`(448,软限/基础,超过即回收·限速)+ `MemoryMax=MAX`(1024,硬限/上界,越界 OOM);**与插件开关无关**(不读 profile 的 bundles)。客户端:`quotaOf()` 兜底值改为**硬顶上界**(进度条 100% 基准与「超限」判据同它),事实行改为显示「**基础 448 MiB → 最多 1024 MiB** · V8 堆 256」;`/api/dsh/status` 的 `quota` 增加 `baseMb`。|v0.3.18(2026-09-14):**内存口径修正 —— 实例配额与「插件开关」解耦**(用户:「实例内存不要受插件开关影响,只受 min 和 max 值影响」)。① 编排器 `instanceMemMb()` 改为只受 MIN/MAX 决定:`min(MIN, MAX)` = **MIN 448 MiB**(删掉 `PLUGIN_MEM_MB` 与 `BASE_MEM_MB`,不再读 profile 的 bundles);② 客户端保留 `MEM_TABLE`,但**只用于给用户看的预估**(`quotaOf()` 不再 clamp 预估值),显示「实际配额」仍优先取 `/api/dsh/status` 的真值;③ 进度条 100% 基准与确认弹窗的「超限」判据都改为**实际配额**(原为硬顶 MAX),文案改为「预估已超过实例配额(配额固定,不会因为多装插件而增加)」;④ 理由(实测):用「估计表」定「硬上限」时估偏低就真 OOM —— admin 带 mcn 后**峰值 409 MiB** 而当时上限 384 ⇒ 一分钟内被 cgroup OOM 杀 4 次、打出 crash 熔断;且配额随开关跳动会让界面「预估」与真实上限绑死。`verify-mem-model.mjs` 已换向断言(反回退:编排器不得再用插件集合算配额)。|v0.3.17(2026-09-14):**内存口径定案**(BASE 448 / MIN 448 / MAX 1024,插件成本表 univer 512、mcn 128)—— 与编排器 `instanceMemMb()` 逐值对齐(`verify-mem-model.mjs` 会红着提醒)。① BASE 160→448:0.1.5 基座**实测 312 MiB**,旧值按 0.1.2 的 106~145 估的,严重低估 ⇒ 384 的上限里只剩 72 MiB 给插件,这正是宿主 swap 被吃掉的原因;② **MIN 必须 ≥ BASE**(否则 `clamp(raw, MIN, MAX)` 的下限失去意义)⇒ MIN 384→448;③ **MAX 保持 1024**(不是偏好:编排器头部已写「上限 1024M 是单实例硬顶(宿主 1870M)」这一不变量,在途草稿的 1536 与宿主容量前提自相矛盾,已驳回);④ mcn 套件**保持 128**:无可支撑抬高的实测,反向有证据(装了它的用户在旧 384 上限下一直跑得住 ⇒ 需求 ≤384),而抬高会直接吃宿主余量(1870M / 可用 ≈761M / swap 已用 452M)。|v0.3.16(2026-09-14):**官方推荐插件列表按 dsh 版本过滤**(用户:「插件管理中的官方推荐插件列表 只能显示匹配当前dsh版本 和 超过当前版本的插件(超过的要明确标注)」)。① 官方目录(`awesome-dsh-plugin.com/plugins.json`)**不带 dsh 版本字段** ⇒ 平台侧逐包查 npm 精简 packument 的 `peerDependencies / engines` 与**平台真实版本**比对,分类 `match / none / newer / older / unknown`(判定在 `src/web/plugin-dsh-compat.ts`);② **默认隐藏 `older`**(只声明了更旧版本),`newer`(要求更高的 dsh)**橙色显著标注**并在 tooltip 给出「需 ≥ X,当前 Y」;③ 说明行给出「已按 dsh X 过滤,隐藏 N 个…」且**勾选框「含仅兼容更旧版本的」是显式逃生口** —— 绝不让条目静默消失;④ 判定结果按 `<npm>@<版本>` 落盘缓存 7 天,首次进页后台预热全量,请求内只给 6s 预算、超预算判 `unknown` 并**照常显示**(网络抖动不该等同「不兼容」);⑤ ⚠️ 语义:判 `satisfies` **必须带 `includePrerelease: true`** —— 平台版本是 prerelease,默认语义下 `^0.1.2` 甚至 `*` 都不满足,实测 TOP300 会把 `dsh-univer-office`/`dshmarket` 这类**在跑的**插件误判为 older(`match 97/older 139` → 容忍后 `match 214/older 22`);且**伞包 `@deepseek-ai/dsh` 必须单独解析**(它不在自己的 node_modules 里,而那 3 条 `newer` 全部只写在伞包上)。|v0.3.15(2026-09-14):**删除条目补二次确认**(官方 `deleteDialog` 的等价物)。原「删除」紧挨着「停用」且**无任何确认** ⇒ 一次误点就连已存储的 API 密钥一起删掉(`DELETE /api/me/keys/:id` 不可撤销,用户只能重新粘贴密钥);现改为弹窗**点名该提供方**(`ms.delTitle` / `ms.delDesc` / `ms.delConfirm`)后才执行,遮罩点击或「取消」可关。|v0.3.14(2026-09-14):**「模型设置」页按官方 `dsh-client-ui-settings-models` 复刻交互**(用户:「admin模型设置和状态 一个名称换行了,另一个两个按钮换Line truncated
"type": "module", "type": "module",
"main": "lib/index.js", "main": "lib/index.js",
"exports": { "exports": {
+51 -48
View File
@@ -1,14 +1,25 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* verify-mem-model.mjs —— 内存模型「单一事实源」交叉校验(档案 84) * verify-mem-model.mjs —— 内存模型「单一事实源」交叉校验(档案 84 → 档案 96 改版)
* *
* 为什么需要:2026-09-13 实测发现「功能管理」那条内存条显示 388 MiB 并报「将超出上限」, * 这个脚本守的是一条**曾经漂过、又改过一次设计**的事实链,历史分三段,都留着:
* 而平台真实配额是 672 / 384 —— 根因是**同一个事实被复制成了两份**: *
* · 编排器 `src/supervisor/orchestrator.ts` 的 PLUGIN_MEM_MB / BASE / MIN / MAX(决定 MemoryMax) * ① 2026-09-13(档案 84):发现问题 —— 「功能管理」页显示 388 MiB 并报「将超出上限」,
* · 客户端 `poc/business-plugins/lib/client.js` 的 MEM_TABLE / MEM_BASE_MIB / …(决定界面显示) * 而平台真实配额是 672/384。根因 = **同一事实被复制成两份**:编排器的 BASE/MIN/MAX/插件表
* R1 把「写死 384」改成「按插件集合推导」时只改了前者 ⇒ 后者原地漂了。 * 与客户端的 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 两处仍逐值一致。
* *
* 本脚本把两处**钉死在一起**:任一常量或表项对不上,`npm run verify` 直接失败。
* 用法:node scripts/verify-mem-model.mjs 退出码 0=全绿 / 1=有失败 * 用法:node scripts/verify-mem-model.mjs 退出码 0=全绿 / 1=有失败
*/ */
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
@@ -28,74 +39,66 @@ const ok = (name, cond, extra = '') => {
const orch = read('src/supervisor/orchestrator.ts') const orch = read('src/supervisor/orchestrator.ts')
const spawner = read('src/supervisor/spawner.ts') const spawner = read('src/supervisor/spawner.ts')
const dshRoute = read('src/web/routes/dsh.ts') const dshRoute = read('src/web/routes/dsh.ts')
const client = read('poc/business-plugins/lib/client.js')
const num = (src, name) => { const num = (src, name) => {
const m = new RegExp('const ' + name + ' = (\\d+)').exec(src) const m = new RegExp('const ' + name + ' = (\\d+)').exec(src)
return m ? Number(m[1]) : null return m ? Number(m[1]) : null
} }
const ORCH = { const ORCH = {
base: num(orch, 'BASE_MEM_MB'),
min: num(orch, 'MIN_MEM_MB'), min: num(orch, 'MIN_MEM_MB'),
max: num(orch, 'MAX_MEM_MB'), max: num(orch, 'MAX_MEM_MB'),
headroom: num(orch, 'HEAP_HEADROOM_MB'), headroom: num(orch, 'HEAP_HEADROOM_MB'),
} }
const orchTable = {} ok('编排器常量齐备(MIN/MAX/HEADROOM)', ORCH.min !== null && ORCH.max !== null && ORCH.headroom !== null,
{
const block = /const PLUGIN_MEM_MB: Record<string, number> = \{([\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)}`) `-> ${JSON.stringify(ORCH)}`)
ok('编排器插件成本表非空', Object.keys(orchTable).length > 0, '-> ' + JSON.stringify(orchTable)) ok('MIN ≤ MAX(配额取 MIN、MAX 作硬顶,顺序不能反)', ORCH.min !== null && ORCH.max !== null && ORCH.min <= ORCH.max,
`-> MIN ${ORCH.min} / MAX ${ORCH.max}`)
// ── ② 客户端(副本)──────────────────────────────────────── // ── ② 反回退:编排器的配额**不得**再依赖插件集合(档案 96 的核心)────────
const client = read('poc/business-plugins/lib/client.js') 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 cnum = (src, name) => {
const m = new RegExp('var ' + name + ' = (\\d+)').exec(src) const m = new RegExp('var ' + name + ' = (\\d+)').exec(src)
return m ? Number(m[1]) : null return m ? Number(m[1]) : null
} }
const CLIENT = { const CLIENT = {
base: cnum(client, 'MEM_BASE_MIB'),
min: cnum(client, 'MEM_MIN_MIB'), min: cnum(client, 'MEM_MIN_MIB'),
max: cnum(client, 'MEM_MAX_MIB'), max: cnum(client, 'MEM_MAX_MIB'),
headroom: cnum(client, 'MEM_HEAP_HEADROOM_MIB'), headroom: cnum(client, 'MEM_HEAP_HEADROOM_MIB'),
} }
const clientTable = {} 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}`)
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])
} }
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 推导两侧同构 ─────────────────────────────────
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 - MEM_HEAP_HEADROOM_MIB\)\)/.test(client))
ok('编排器 heap 推导算法未变', /Math\.max\(128, Math\.min\(256, memMb - HEAP_HEADROOM_MB\)\)/.test(orch)) ok('编排器 heap 推导算法未变', /Math\.max\(128, Math\.min\(256, memMb - HEAP_HEADROOM_MB\)\)/.test(orch))
// ── ⑤ 断言:status 真的把真值透出来了(否则前端的「读真值」是空接线)── // ── ⑤ status 真的把真值透出来了(否则前端「读真值」是空接线)──
ok('Spawner 接口声明 quotaInfo?', /quotaInfo\?\(userId: string\): \{ memMb: number; heapMb: number \} \| null/.test(spawner)) ok('Spawner 接口声明 quotaInfo?', /quotaInfo\?\(userId: string\): { baseMb: number; memMb: number; heapMb: number } \| null/.test(spawner))
ok('编排器实现 quotaInfo', /quotaInfo\(userId: string\): \{ memMb: number; heapMb: number \} \| null \{/.test(orch)) ok('编排器实现 quotaInfo', /quotaInfo\(userId: string\): { baseMb: number; memMb: number; heapMb: number } \| null \{/.test(orch))
// 档案 87 顺手修:档案 86 把 status 主体抽成 `statusForUser(app, user)` 之后,这里从
// `request.user!.id` 变成了 `user.id` ⇒ 老断言正则失配、`npm run verify` 一直红着。
// 断言意图不变("status 真的把真值透出来了"),改成不绑定形参名。
ok('/api/dsh/status 返回 quota', /quota:\s*app\.supervisor\.quotaInfo\?\.\([^)]*\)\s*\?\?\s*null/.test(dshRoute)) 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)) ok('客户端会去读 /api/dsh/status(显示真配额)', /\/api\/dsh\/status/.test(client) && /setQuotaInfo\(/.test(client))
console.log(failed === 0 ? '\n结论:全绿 ✅' : '\n结论:有 ' + failed + ' 项失败 ❌') console.log(failed === 0 ? '\n结论:全绿 ✅' : '\n结论:有 ' + failed + ' 项失败 ❌')
process.exit(failed === 0 ? 0 : 1) process.exit(failed === 0 ? 0 : 1)
+40 -46
View File
@@ -63,51 +63,45 @@ export {
* missing task; executes any post-restart command from the handoff path. */ * missing task; executes any post-restart command from the handoff path. */
const WATCHDOG_TASK = 'Read DSHS_HANDOFF_PATH. If it contains a JSON {"command": ...}, run that command. Then exit.' const WATCHDOG_TASK = 'Read DSHS_HANDOFF_PATH. If it contains a JSON {"command": ...}, run that command. Then exit.'
/* ───────────────────────────────────────────────────────────────────────────── /* ─────────────────────────────────────────────────────────────────────────────
* 档案 81 · R1-④:**实例内存配额按「已启用插件集合」推导**(原先写死 384M) * 实例内存:**基础 MIN、最多浮动到 MAX**(2026-09-14 用户要求 —— 与「插件开关」解耦)
* *
* 为什么改(2026-09-13 事故,实测): * 两段历史都记下来,免得再走回头路:
* guest 启用 `dsh-univer-office` 后,其 gateway **单进程 RSS ≈390 MB**,而实例 cgroup 上限 * · **档案 81 · R1-④(2026-09-13)** 曾把配额改成 `基准 + Σ(插件内存预估)`,为修
* 写死 384M ⇒ 实例起来即被 **cgroup OOM kill(exitCode 137)** ⇒ 页面「恢复 → 起来 → 又被杀」 * 「guest 启用 univer 后 gateway ≈390 MB 撞死写的 384M ⇒ cgroup OOM kill 死循环」。
* 死循环(每 ~35s 一次 GET /)。**根因不是恢复逻辑,而是配额与插件集合脱钩。** * · **2026-09-14 实测暴露该设计的软肋:用「估计表」去定「硬上限」,估偏低就真 OOM** ——
* ① 未知插件按 0 计 ⇒ 装了重插件也不加配额;
* ② 表本身偏乐观:admin 带上 mcn 后**峰值 409 MiB**,而当时上限 384 ⇒ **一分钟内被 OOM 杀 4 次**,
* 直接打出 crash 熔断(`/var/log/dsh-crash-breaker.log` 20:04:53);
* ③ 配额随「开关插件」跳动 ⇒ 界面上的「预估」与实例**真实上限**绑死,语义混乱
* (用户 2026-09-14 指出:「开关插件只是显示给用户看内存占用的预估,怎么会实际影响实例内存」)。
* *
* 规则:`基准 + Σ(插件内存预估)`,再加下限/上限封顶;V8 老生代上限跟随配额(留 96M 余量)。 * 现规则(用户原话:「实例内存不要受插件开关影响,只受 min 和 max 值影响」;
* · 预估表口径承接档案 75/67(插件评估的「资源成本」维度); * 随后明确「改成基础 min 最大可以浮动到 max」)——**cgroup 给两个参数**:
* · 未识别的插件按 0 计(保守:宁可少给,也不虚高); * `MemoryHigh = MIN_MEM_MB`(**基础/软限**:超过即开始回收·限速)+
* · 上限 1024M 是单实例硬顶(宿主 1870M,并发数由 maxIdleInstances + available 决定)。 * `MemoryMax = MAX_MEM_MB`(**上界/硬限**:越界 OOM)⇒ 实际占用在两者之间浮动;
* 回滚:把下方 PLUGIN_MEM_MB 清空即可退回「MIN_MEM_MB」单一档。 * V8 老生代上限仍跟随配额(留 `HEAP_HEADROOM_MB` 余量,见 `heapMbFor`)。
* ⚠️ **插件成本表已从配额计算中移除**:给用户看的预估仍在插件客户端(`MEM_TABLE`),
* 但它**只用于显示**、不再是硬上限的来源。
*
* 取值依据:MIN = **448**(= 用户 2026-09-14 裁定的 base「base 448 没问题」;0.1.5 基座实测 ~312 MiB ⇒ 留 ~136 MiB 余量);
* MAX = 1024 是宿主容量不变量(宿主 1870M,并发数由 maxIdleInstances + available 决定)。
* 回滚:把 `instanceMemMb()` 换回「基准 + Σ插件表」即可(旧实现见 git 历史;档案 81/94/96 记有两次调整理由)。
* ──────────────────────────────────────────────────────────────────────────── */ * ──────────────────────────────────────────────────────────────────────────── */
const PLUGIN_MEM_MB: Record<string, number> = { // MIN = **基础值**(cgroup `MemoryHigh`)—— 实例稳态落在这附近;MAX = **上浮上界**(cgroup `MemoryMax`,越界 OOM)。
'dsh-univer-office': 512, // 实测其 gateway 单进程 ≈390 MB(2026-09-13 14:44 384→512:544 仍欠 ~50-120 MiB,实测 gateway 起不来;512 ⇒ 配额 672) // ⚠️ 448 = 用户裁定的 base,**别擅自调**(2026-09-14:我曾擅自抬到 512,被纠正)。
// ⚠️ mcn 套件**保持 128**(2026-09-14 定案):没有实测依据支撑抬高 —— 反而有反向证据:
// 装了它的那个用户在**旧 384 上限**下一直跑得住 ⇒ 需求 ≤384。上限抬升会直接吃宿主机余量
// (宿主 1870M / 可用 ≈761M / swap 已用 452M),等有实测再抬。
'dsh-plugin-mcn-suite': 128, // 7 插件整合包(含 xlsx 等重型依赖)
}
// BASE 448(2026-09-14 用户裁定):0.1.5 基座**实测 312 MiB**,旧值 160(按 0.1.2 的 106~145)严重低估
// —— 384 的上限里只剩 72 MiB 给插件,正是宿主 swap 被吃掉的原因;抬高 BASE 是**修正低估**。
const BASE_MEM_MB = 448
// MIN 必须 ≥ BASE,否则 `clamp(mb, MIN, MAX)` 里的 MIN 失去意义(永远不会低于 BASE)。
// 故取 MIN = BASE = 448(旧值 384 在 BASE=160 时满足该不变量,抬 BASE 后必须跟着抬)。
const MIN_MEM_MB = 448 const MIN_MEM_MB = 448
// MAX 保持 1024 —— 这不是偏好,而是**本文档头部已写明的不变量**:
// 「上限 1024M 是单实例硬顶(宿主 1870M)」。2026-09-14 用户裁定「max 就是 1024」;
// 在途草稿里的 1536 与宿主机容量前提**自相矛盾**,已驳回。
const MAX_MEM_MB = 1024 const MAX_MEM_MB = 1024
const HEAP_HEADROOM_MB = 96 // 配额里留给非堆部分(native/栈/共享) const HEAP_HEADROOM_MB = 96 // 配额里留给非堆部分(native/栈/共享)
/** 读 profile 的 bundles,推导该实例应有的 cgroup 上限(MB)。读不到就用下限。 */ /** 实例内存**基础值**(MB)= cgroup 软限 `MemoryHigh`:超过它内核开始回收/限速。
function instanceMemMb(profileDir: string): number { * **不再读 profile 的 bundles**(2026-09-14 起与「插件开关」解耦)。 */
try { function instanceBaseMb(): number {
const pkg = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as { return Math.max(128, MIN_MEM_MB)
dsh?: { profile?: { bundles?: string[] } } }
}
const bundles = pkg.dsh?.profile?.bundles ?? [] /** 实例内存**上浮上界**(MB)= cgroup 硬限 `MemoryMax`:越界 OOM;V8 堆也按它推导。 */
let mb = BASE_MEM_MB function instanceMaxMb(): number {
for (const b of bundles) mb += PLUGIN_MEM_MB[b] ?? 0 return Math.max(instanceBaseMb(), MAX_MEM_MB)
return Math.min(Math.max(mb, MIN_MEM_MB), MAX_MEM_MB)
} catch {
return MIN_MEM_MB
}
} }
/** V8 老生代上限跟随配额(不再写死 160)。 */ /** V8 老生代上限跟随配额(不再写死 160)。 */
@@ -467,7 +461,7 @@ export class LocalSpawner implements Spawner {
private async baseEnv(userId: string): Promise<Record<string, string>> { private async baseEnv(userId: string): Promise<Record<string, string>> {
// 档案 81 · R1-④:堆上限跟随「已启用插件集合」推导出的配额(不再写死 160) // 档案 81 · R1-④:堆上限跟随「已启用插件集合」推导出的配额(不再写死 160)
const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')) const memMb = instanceMaxMb()
const root = userRoot(this.config.dataRoot, userId) const root = userRoot(this.config.dataRoot, userId)
const home = homeRoot(root) const home = homeRoot(root)
const workspace = workspaceRoot(root) const workspace = workspaceRoot(root)
@@ -571,7 +565,7 @@ export class LocalSpawner implements Spawner {
// 档案 81 · R1-④:本实例的内存配额由「已启用插件集合」推导(算一次,传给 spawnAsUser 用) // 档案 81 · R1-④:本实例的内存配额由「已启用插件集合」推导(算一次,传给 spawnAsUser 用)
const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')) const memMb = instanceMaxMb()
const env: Record<string, string> = { const env: Record<string, string> = {
...(await this.baseEnv(userId)), ...(await this.baseEnv(userId)),
@@ -739,7 +733,7 @@ export class LocalSpawner implements Spawner {
const unit = `dsh-${uid}-${randomUUID().slice(0, 8)}` const unit = `dsh-${uid}-${randomUUID().slice(0, 8)}`
// 档案 81 · R1-④:cgroup 上限由「已启用插件集合」推导(原先写死 384M;见文件头注释的事故) // 档案 81 · R1-④:cgroup 上限由「已启用插件集合」推导(原先写死 384M;见文件头注释的事故)
const memMb = instanceMemMb(join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')) const memMb = instanceMaxMb()
// 档案 58(2026-09-12):MemoryMax 512M → 384M。 // 档案 58(2026-09-12):MemoryMax 512M → 384M。
// 依据(全部来自实测,见 scripts/probe-instance-mem.cjs): // 依据(全部来自实测,见 scripts/probe-instance-mem.cjs):
// · dsh 自身私有内存稳态 106~117 MiB、峰值约 145 MiB(= VmHWM 206 减去共享的 62 MiB // · dsh 自身私有内存稳态 106~117 MiB、峰值约 145 MiB(= VmHWM 206 减去共享的 62 MiB
@@ -756,7 +750,8 @@ export class LocalSpawner implements Spawner {
// 的那一层;本行是兜底硬限。两者一起看才有意义。 // 的那一层;本行是兜底硬限。两者一起看才有意义。
const sdArgs = [ const sdArgs = [
'--scope', '--unit', unit, '--scope', '--unit', unit,
'-p', 'MemoryMax=' + memMb + 'M', // 档案 81 R1-④:按插件集合推导 '-p', 'MemoryHigh=' + instanceBaseMb() + 'M', // **基础**(软限,超过即回收/限速)
'-p', 'MemoryMax=' + memMb + 'M', // **上界**(硬限,越界 OOM)
'-p', 'CPUQuota=150%', '-p', 'CPUQuota=150%',
'-p', 'TasksMax=128', '-p', 'TasksMax=128',
'--', 'bwrap', ...bwrapArgs, '--', 'bwrap', ...bwrapArgs,
@@ -937,11 +932,10 @@ export class LocalSpawner implements Spawner {
* 并报「⚠ 将超出上限」,而平台真实配额是 672 / 384,且 384 早已只是**下限**)。 * 并报「⚠ 将超出上限」,而平台真实配额是 672 / 384,且 384 早已只是**下限**)。
* 与其让第二份副本再漂一次,不如把已有值暴露出去、让前端读真值。 * 与其让第二份副本再漂一次,不如把已有值暴露出去、让前端读真值。
*/ */
quotaInfo(userId: string): { memMb: number; heapMb: number } | null { quotaInfo(userId: string): { baseMb: number; memMb: number; heapMb: number } | null {
try { try {
const profileDir = join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web') const memMb = instanceMaxMb()
const memMb = instanceMemMb(profileDir) return { baseMb: instanceBaseMb(), memMb, heapMb: heapMbFor(memMb) }
return { memMb, heapMb: heapMbFor(memMb) }
} catch { } catch {
return null return null
} }
+1 -1
View File
@@ -117,7 +117,7 @@ export interface Spawner {
* 返回 `instanceMemMb()` / `heapMbFor()` 的**同源结果**,供实例内的「功能管理」 * 返回 `instanceMemMb()` / `heapMbFor()` 的**同源结果**,供实例内的「功能管理」
* 直接显示真值,而不是各自维护一份必然会漂的估算表。 * 直接显示真值,而不是各自维护一份必然会漂的估算表。
*/ */
quotaInfo?(userId: string): { memMb: number; heapMb: number } | null quotaInfo?(userId: string): { baseMb: number; memMb: number; heapMb: number } | null
/** Restart every running main so a swapped global API key takes effect (env is a spawn-time snapshot). */ /** Restart every running main so a swapped global API key takes effect (env is a spawn-time snapshot). */
restartAllMains(): Promise<void> restartAllMains(): Promise<void>