用户原话两段:①「改为 实例内存不要受插件开关影响,只受 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 的配额口径同步(那两个文件本就有别人未提交的改动)。
1157 lines
59 KiB
TypeScript
1157 lines
59 KiB
TypeScript
/**
|
||
* Per-user DSH supervisor: a resident main DSH plus an **on-demand** watchdog.
|
||
*
|
||
* The watchdog is not spawned at launch; it is pulled up once when the main
|
||
* crashes (to repair) or when a post-restart command must be executed. This
|
||
* keeps the steady-state footprint at one process per active user while still
|
||
* providing crash repair + command handoff. The watchdog's agent-level
|
||
* repair/session-resume is harness-internal and deferred to real-harness
|
||
* integration (see docs/blueprint.md §4).
|
||
* @module dshs/supervisor/orchestrator
|
||
*/
|
||
|
||
import { execFileSync, spawn, type ChildProcess, type StdioOptions } from 'node:child_process'
|
||
import { randomUUID } from 'node:crypto'
|
||
import {
|
||
appendFileSync,
|
||
chmodSync,
|
||
chownSync,
|
||
existsSync,
|
||
mkdirSync,
|
||
readFileSync,
|
||
realpathSync,
|
||
writeFileSync,
|
||
} from 'node:fs'
|
||
import { join } from 'node:path'
|
||
import type { ServerConfig } from '../config.js'
|
||
import { handoffPath, homeRoot, userRoot, workspaceRoot } from '../fs/workspace.js'
|
||
import {
|
||
breakerActive,
|
||
breakerCooldownMs,
|
||
breakerUntil,
|
||
decideCrashAction,
|
||
openBreaker,
|
||
pruneHistory,
|
||
type BreakerPolicy,
|
||
type BreakerState,
|
||
type CrashPolicyConfig,
|
||
} from './crash-policy.js'
|
||
import { createPortGuard, type PortGuard } from './firewall.js'
|
||
import { findFreePort, scrubEnv } from './spawn.js'
|
||
import {
|
||
AlreadyRunningError,
|
||
CrashBreakerOpenError,
|
||
type Endpoint,
|
||
type Instance,
|
||
type InstanceRole,
|
||
type InstanceStatus,
|
||
type Spawner,
|
||
type UserStatus,
|
||
} from './spawner.js'
|
||
|
||
// Re-exported so existing importers (routes) keep resolving from this module.
|
||
export {
|
||
AlreadyRunningError,
|
||
CrashBreakerOpenError,
|
||
type Instance,
|
||
type InstanceRole,
|
||
type InstanceStatus,
|
||
type UserStatus,
|
||
}
|
||
|
||
/** Task given to the one-shot headless watchdog so it doesn't error on a
|
||
* 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.'
|
||
/* ─────────────────────────────────────────────────────────────────────────────
|
||
* 实例内存:**基础 MIN、最多浮动到 MAX**(2026-09-14 用户要求 —— 与「插件开关」解耦)
|
||
*
|
||
* 两段历史都记下来,免得再走回头路:
|
||
* · **档案 81 · R1-④(2026-09-13)** 曾把配额改成 `基准 + Σ(插件内存预估)`,为修
|
||
* 「guest 启用 univer 后 gateway ≈390 MB 撞死写的 384M ⇒ cgroup OOM kill 死循环」。
|
||
* · **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 指出:「开关插件只是显示给用户看内存占用的预估,怎么会实际影响实例内存」)。
|
||
*
|
||
* 现规则(用户原话:「实例内存不要受插件开关影响,只受 min 和 max 值影响」;
|
||
* 随后明确「改成基础 min 最大可以浮动到 max」)——**cgroup 给两个参数**:
|
||
* `MemoryHigh = MIN_MEM_MB`(**基础/软限**:超过即开始回收·限速)+
|
||
* `MemoryMax = MAX_MEM_MB`(**上界/硬限**:越界 OOM)⇒ 实际占用在两者之间浮动;
|
||
* 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 记有两次调整理由)。
|
||
* ──────────────────────────────────────────────────────────────────────────── */
|
||
// MIN = **基础值**(cgroup `MemoryHigh`)—— 实例稳态落在这附近;MAX = **上浮上界**(cgroup `MemoryMax`,越界 OOM)。
|
||
// ⚠️ 448 = 用户裁定的 base,**别擅自调**(2026-09-14:我曾擅自抬到 512,被纠正)。
|
||
const MIN_MEM_MB = 448
|
||
const MAX_MEM_MB = 1024
|
||
const HEAP_HEADROOM_MB = 96 // 配额里留给非堆部分(native/栈/共享)
|
||
|
||
/** 实例内存**基础值**(MB)= cgroup 软限 `MemoryHigh`:超过它内核开始回收/限速。
|
||
* **不再读 profile 的 bundles**(2026-09-14 起与「插件开关」解耦)。 */
|
||
function instanceBaseMb(): number {
|
||
return Math.max(128, MIN_MEM_MB)
|
||
}
|
||
|
||
/** 实例内存**上浮上界**(MB)= cgroup 硬限 `MemoryMax`:越界 OOM;V8 堆也按它推导。 */
|
||
function instanceMaxMb(): number {
|
||
return Math.max(instanceBaseMb(), MAX_MEM_MB)
|
||
}
|
||
|
||
/** V8 老生代上限跟随配额(不再写死 160)。 */
|
||
function heapMbFor(memMb: number): number {
|
||
return Math.max(128, Math.min(256, memMb - HEAP_HEADROOM_MB))
|
||
}
|
||
|
||
/**
|
||
* 把「堆上限」从既有 NODE_OPTIONS 里摘掉,换成按配额推导的值。
|
||
*
|
||
* 为什么不让 env 决定堆:`DSH_INSTANCE_NODE_OPTIONS` 曾在平台进程 env 里被设成
|
||
* `--max-old-space-size=160`(档案 74 的下调),而该值**不在** systemd 的 manager env / unit 里
|
||
* (2026-09-13 实测:`systemctl unset-environment` 后进程 env 里仍有)⇒ 靠改配置改不动它。
|
||
* 规则改为:**堆由代码按配额推导**(与 MemoryMax 同一口径,不可能再对不上);
|
||
* env 仍然有效,但只承载**其它**选项(如 `--trace-gc`)。要强行指定堆,用 `DSHS_HEAP_MB_OVERRIDE`。
|
||
*/
|
||
function withHeap(base: string | undefined, memMb: number): string {
|
||
const override = Number(process.env.DSHS_HEAP_MB_OVERRIDE ?? '')
|
||
const mb = Number.isFinite(override) && override > 0 ? override : heapMbFor(memMb)
|
||
const rest = (base ?? '')
|
||
.split(/\s+/)
|
||
.filter((t) => t !== '' && !t.startsWith('--max-old-space-size'))
|
||
rest.push(`--max-old-space-size=${mb}`)
|
||
return rest.join(' ')
|
||
}
|
||
|
||
|
||
/**
|
||
* Local backend: owns the lifecycle of per-user DSH process pairs via
|
||
* child_process. State is in-memory. Implements {@link Spawner}.
|
||
*/
|
||
export class LocalSpawner implements Spawner {
|
||
private readonly mains = new Map<string, Instance>()
|
||
private readonly watchdogs = new Map<string, Instance>()
|
||
private readonly children = new Map<string, ChildProcess>()
|
||
private readonly restartTimers = new Map<string, NodeJS.Timeout>()
|
||
/** 熔断窗口内的自动重启时间戳(档案 20)。 */
|
||
private readonly crashHistory = new Map<string, number[]>()
|
||
/** 自上次稳定运行以来的连续重启次数(驱动指数退避,档案 20)。 */
|
||
private readonly crashStreak = new Map<string, number>()
|
||
/** 稳定判定定时器:连续运行达 `crashStableMs` 即视为已恢复(档案 20)。 */
|
||
private readonly stableTimers = new Map<string, NodeJS.Timeout>()
|
||
|
||
/**
|
||
* 档案 78:熔断冷却状态(**跨轮存活** —— `resetCrashState()` 刻意不清它)。
|
||
* 冷却期内拒绝**隐式**启动;冷却过后只给一次干净预算。key = userId。
|
||
*/
|
||
private readonly breaker = new Map<string, BreakerState>()
|
||
/** Last activity per user (ms epoch) — fed by proxied traffic / enter. */
|
||
private readonly lastActive = new Map<string, number>()
|
||
private readonly reapTimer: NodeJS.Timeout | undefined
|
||
|
||
private readonly portGuard: PortGuard | undefined
|
||
|
||
constructor(
|
||
private readonly config: ServerConfig,
|
||
/** Resolve the user's own API key (decrypted); null = user has none. */
|
||
private readonly resolveApiKey: (userId: string) => Promise<string | null>,
|
||
/** Resolve the user's assigned Linux uid (falls back to hash when unset). */
|
||
private readonly resolveUid: (userId: string) => Promise<number>,
|
||
) {
|
||
this.portGuard = createPortGuard(config.portGuard)
|
||
// 档案 30:portal 启动即清掉遗留实例 scope(重启后无法接管)。
|
||
this.cleanAllStaleScopes()
|
||
// Local-mode idle reap: periodically stop mains that are idle past the TTL,
|
||
// then cap the resident count (LRU by last activity). Only armed when at
|
||
// least one of the two rules is enabled. The timer is unref'd so it never
|
||
// keeps the process alive by itself.
|
||
if (
|
||
config.idleReapIntervalSeconds > 0 &&
|
||
(config.instanceIdleTtlSeconds > 0 || config.maxIdleInstances > 0)
|
||
) {
|
||
this.reapTimer = setInterval(() => void this.reapOnce(), config.idleReapIntervalSeconds * 1000)
|
||
this.reapTimer.unref()
|
||
}
|
||
}
|
||
|
||
/** Record activity so a warm instance is not treated as idle. */
|
||
touch(userId: string): void {
|
||
this.lastActive.set(userId, Date.now())
|
||
}
|
||
|
||
/** Idle-reap pass: ① stop mains idle past the TTL; ② cap resident count by
|
||
* least-recent activity. Only stops processes — never touches user data. */
|
||
private async reapOnce(): Promise<void> {
|
||
const now = Date.now()
|
||
const ttlMs = this.config.instanceIdleTtlSeconds * 1000
|
||
const max = this.config.maxIdleInstances
|
||
const reaped = new Set<string>()
|
||
const reaperLog = (userId: string, reason: string): void => {
|
||
const idleSecs = Math.round((now - (this.lastActive.get(userId) ?? now)) / 1000)
|
||
process.stderr.write(`[idle-reap] stop ${userId} (${reason}; idle ${idleSecs}s)\n`)
|
||
}
|
||
if (ttlMs > 0) {
|
||
for (const userId of [...this.mains.keys()]) {
|
||
const last = this.lastActive.get(userId) ?? now
|
||
if (now - last > ttlMs) {
|
||
reaped.add(userId)
|
||
reaperLog(userId, `idle>${this.config.instanceIdleTtlSeconds}s`)
|
||
await this.stop(userId)
|
||
}
|
||
}
|
||
}
|
||
if (max > 0) {
|
||
const running = [...this.mains.keys()].filter((userId) => !reaped.has(userId))
|
||
if (running.length > max) {
|
||
const excess = running
|
||
.sort((a, b) => (this.lastActive.get(a) ?? 0) - (this.lastActive.get(b) ?? 0))
|
||
.slice(0, running.length - max)
|
||
for (const userId of excess) {
|
||
reaperLog(userId, `cap>${max} lru`)
|
||
await this.stop(userId)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Spawn the resident main DSH for a user (watchdog is pulled up on demand). */
|
||
async launch(
|
||
userId: string,
|
||
folder: string,
|
||
patch?: string,
|
||
opts?: { force?: boolean },
|
||
): Promise<Instance> {
|
||
if (this.mains.has(userId)) throw new AlreadyRunningError(userId)
|
||
// 档案 78:冷却期内拒绝**隐式**启动(`/api/dsh/enter` 与「用户选文件夹」都会走到这里)。
|
||
// 原实现里 circuit-open 会 resetCrashState() 清空预算 ⇒ 只要有人(用户 F5、注入脚本自愈、
|
||
// 脚本直铺)不断重试,崩溃循环就能无限重复。`force: true` 供**平台自身**的显式操作绕开。
|
||
if (opts?.force === true) this.clearBreaker(userId, 'forced-launch')
|
||
else this.assertBreakerClosed(userId)
|
||
// 显式启动 = 新一轮:清空崩溃计数(用户重新进入后应拿到完整重试预算,档案 20)。
|
||
this.resetCrashState(userId)
|
||
// 档案 18 v3 收尾:启动前补齐"受限目录选择器"(幂等;首登 profile 尚未创建时会自行跳过)。
|
||
this.ensurePickerProfile(userId)
|
||
this.touch(userId)
|
||
const instance = await this.spawnInstance(userId, 'main', folder, patch)
|
||
// 等待 dsh web 打印 launch token,保证返回的打开 URL 可直接通过浏览器认证
|
||
await this.waitForLaunchToken(instance)
|
||
return instance
|
||
}
|
||
|
||
/**
|
||
* 档案 18 v3 收尾(第二层自愈):确保该用户的 profile 具备"受限目录选择器"
|
||
* (平台段 + 插件包)—— 复用平台脚本 `ensure-workspace-picker.cjs --user-id <id>`(幂等)。
|
||
* 异步 fire-and-forget:不阻塞启动;失败只记日志(第一层由 provisioning 钩子兜底)。
|
||
* 覆盖场景:profile 被清空/重建、手工删掉平台段、插件被卸载。
|
||
*/
|
||
private ensurePickerProfile(userId: string): void {
|
||
const script =
|
||
process.env.DSH_PICKER_ENSURE_SCRIPT ??
|
||
join(process.cwd(), 'poc', 'workspace-scoped-picker', 'ensure-workspace-picker.cjs')
|
||
if (!existsSync(script)) return
|
||
try {
|
||
const child = spawn('node', [script, '--user-id', userId], { detached: true, stdio: 'ignore' })
|
||
child.on('error', (err) => {
|
||
process.stderr.write(`[picker-ensure] spawn failed: ${err.message}\n`)
|
||
})
|
||
child.unref()
|
||
} catch (err) {
|
||
process.stderr.write(
|
||
`[picker-ensure] failed for ${userId}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||
)
|
||
}
|
||
}
|
||
|
||
/** Stop the current main (clean) and respawn it with the same folder/patch. */
|
||
async restartMain(userId: string): Promise<Instance | undefined> {
|
||
const current = this.mains.get(userId)
|
||
if (current === undefined) return undefined
|
||
// 人工/接口触发的重启不是崩溃:重置计数,避免占用熔断预算(档案 20)。
|
||
this.resetCrashState(userId)
|
||
this.killInstance(userId, current)
|
||
this.touch(userId)
|
||
const instance = await this.spawnInstance(userId, 'main', current.folder, current.patch)
|
||
await this.waitForLaunchToken(instance)
|
||
return instance
|
||
}
|
||
|
||
/**
|
||
* 档案 34:重启实例并**探活**(功能插件启用后的可用性判定)。
|
||
*
|
||
* `restartMain` 内部已等 launch token(20s),但它吞掉了结论;这里叠加一个稳定窗口
|
||
* 并给出显式判定,供调用方决定「保留该插件」还是「回滚 + 标记为不兼容」。
|
||
* 返回 `ok:false` 时,调用方必须回滚——否则插件会把实例拖进崩溃循环(档案 25 教训)。
|
||
* 人工/接口触发的重启不计入熔断预算(`restartMain` 内已 resetCrashState)。
|
||
*/
|
||
/**
|
||
* 档案 34:重启实例并**探活**(功能插件启用后的可用性判定)。
|
||
*
|
||
* 判定链:先确保有实例(无则按 /api/dsh/enter 同路径 launch 一个——否则 restartMain 返回
|
||
* undefined,会把无辜插件误判为不兼容)→ 重启 → **轮询**到 launch token 且状态 running →
|
||
* 再过稳定窗口确认没闪崩。任一步失败即返回 ok:false 并给出真实原因(例如 dsh 的
|
||
* `duplicate loader entry id`),调用方据此回滚并标记该插件。
|
||
*
|
||
* 预算:launch/restartMain 内部各等 20s,这里再给 `budgetMs`(默认 60s)轮询——冷启动较慢的
|
||
* 插件不该被误判(实测 2.5s 固定等待会把好插件判死)。
|
||
*/
|
||
async restartAndProbe(userId: string, budgetMs = 60000): Promise<{ ok: boolean; reason: string }> {
|
||
if (this.mains.get(userId) === undefined) {
|
||
try {
|
||
// 档案 78:插件启用/隔离是平台自身的显式操作 → force 绕开熔断冷却
|
||
// (否则「插件把实例搞崩 → 熔断 → 想自动禁用该插件」会被自己的冷却挡住)。
|
||
await this.launch(userId, workspaceRoot(userRoot(this.config.dataRoot, userId)), undefined, {
|
||
force: true,
|
||
})
|
||
} catch (err) {
|
||
return { ok: false, reason: `实例启动异常:${err instanceof Error ? err.message : String(err)}` }
|
||
}
|
||
} else {
|
||
const inst = await this.restartMain(userId)
|
||
if (inst === undefined) return { ok: false, reason: "实例未启动" }
|
||
await this.spawnWatchdog(userId)
|
||
}
|
||
|
||
const deadline = Date.now() + budgetMs
|
||
let lastReason = ""
|
||
while (Date.now() < deadline) {
|
||
const m = this.mains.get(userId)
|
||
if (m === undefined) return { ok: false, reason: "重启后实例消失" }
|
||
if (m.status === "crashed" || m.status === "stopped") return { ok: false, reason: m.lastError ?? "实例启动后崩溃" }
|
||
if (m.launchToken !== undefined) {
|
||
// 稳定窗口:拿到 token 后立刻崩的插件也算失败(避免"闪一下就死"被当作成功)。
|
||
await new Promise((resolve) => setTimeout(resolve, 2500))
|
||
const a = this.mains.get(userId)
|
||
if (a === undefined) return { ok: false, reason: "重启后实例消失" }
|
||
if (a.status === "crashed" || a.status === "stopped") { lastReason = a.lastError ?? "实例启动后崩溃"; continue }
|
||
if (a.launchToken !== undefined) return { ok: true, reason: "" }
|
||
}
|
||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||
}
|
||
return { ok: false, reason: lastReason !== "" ? lastReason : "实例启动超时(未产出 launch token)" }
|
||
}
|
||
|
||
/** Restart every running main so a swapped global API key takes effect. */
|
||
async restartAllMains(): Promise<void> {
|
||
for (const userId of [...this.mains.keys()]) {
|
||
try {
|
||
await this.restartMain(userId)
|
||
} catch (err) {
|
||
// One user's spawn failure must not abort the broadcast restart.
|
||
console.error(`restartAllMains: restart ${userId} failed`, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
/** Spawn a one-shot watchdog for the user's current main (repair / execute). */
|
||
async spawnWatchdog(userId: string): Promise<Instance | undefined> {
|
||
if (!this.config.enablePatch) return undefined // watchdog needs the runtime patch
|
||
if (this.watchdogs.has(userId)) return this.watchdogs.get(userId)
|
||
const main = this.mains.get(userId)
|
||
if (main === undefined) return undefined
|
||
return await this.spawnInstance(userId, 'watchdog', main.folder, main.patch)
|
||
}
|
||
|
||
/** Current main + watchdog for a user. */
|
||
async status(userId: string): Promise<UserStatus> {
|
||
return { main: this.mains.get(userId), watchdog: this.watchdogs.get(userId) }
|
||
}
|
||
|
||
/** Endpoint the proxy forwards to (local → the running main's loopback port). */
|
||
async endpointFor(userId: string): Promise<Endpoint | undefined> {
|
||
const port = this.mains.get(userId)?.port
|
||
return port === undefined ? undefined : { host: '127.0.0.1', port }
|
||
}
|
||
|
||
/** 等待该用户 main 实例打印 launch token(本地模式启动完成信号),供 enter 复用分支
|
||
* 在返回打开 URL 前调用;无实例/已崩溃/已停则立即返回。 */
|
||
async waitForLaunchTokenForUser(userId: string, timeoutMs = 20000): Promise<void> {
|
||
const main = this.mains.get(userId)
|
||
if (main === undefined) return
|
||
await this.waitForLaunchToken(main, timeoutMs)
|
||
}
|
||
|
||
/** Stop both processes for a user (cancelling any pending restart). */
|
||
async stop(userId: string): Promise<void> {
|
||
const timer = this.restartTimers.get(userId)
|
||
if (timer !== undefined) {
|
||
clearTimeout(timer)
|
||
this.restartTimers.delete(userId)
|
||
}
|
||
const main = this.mains.get(userId)
|
||
const watchdog = this.watchdogs.get(userId)
|
||
if (main !== undefined) this.killInstance(userId, main)
|
||
if (watchdog !== undefined) this.killInstance(userId, watchdog)
|
||
this.mains.delete(userId)
|
||
this.watchdogs.delete(userId)
|
||
this.lastActive.delete(userId)
|
||
this.resetCrashState(userId)
|
||
}
|
||
|
||
/** Stop every tracked process on shutdown. */
|
||
async teardown(): Promise<void> {
|
||
if (this.reapTimer !== undefined) clearInterval(this.reapTimer)
|
||
for (const userId of [...this.mains.keys(), ...this.watchdogs.keys()]) await this.stop(userId)
|
||
}
|
||
|
||
/** No-op: local mode has no sidecar — the control plane owns the volume. */
|
||
async ensureFileService(_userId: string): Promise<void> {}
|
||
|
||
private handoffPath(userId: string): string {
|
||
return handoffPath(userRoot(this.config.dataRoot, userId))
|
||
}
|
||
|
||
/**
|
||
* 预置默认工作区(幂等,仅在工作区列表为空时写入):
|
||
* 把 `<userRoot>/ws` 以短标题「我的工作区」写进 `<home>/storages/workspace.json`,
|
||
* 使新用户/清空后无需手动选择工作区即可开始会话;已有工作区时**不覆盖**用户现状。
|
||
* 失败不阻断启动(只记日志)。
|
||
*/
|
||
private async seedDefaultWorkspace(userId: string): Promise<void> {
|
||
const root = userRoot(this.config.dataRoot, userId)
|
||
const dir = join(homeRoot(root), 'storages')
|
||
const file = join(dir, 'workspace.json')
|
||
try {
|
||
mkdirSync(dir, { recursive: true })
|
||
let existing: { global?: { workspaceIds?: unknown } } | undefined
|
||
try {
|
||
existing = JSON.parse(readFileSync(file, 'utf8')) as { global?: { workspaceIds?: unknown } }
|
||
} catch {
|
||
existing = undefined
|
||
}
|
||
const ids = existing?.global?.workspaceIds
|
||
if (Array.isArray(ids) && ids.length > 0) return // 已有工作区 → 尊重用户现状
|
||
const ws = workspaceRoot(root)
|
||
const id = randomUUID()
|
||
const now = new Date().toISOString()
|
||
const seed = {
|
||
unit: { name: 'workspace', version: 2 },
|
||
global: { initialized: true, workspaceIds: [id], archivedSessionIds: [] },
|
||
tables: {
|
||
workspaces: {
|
||
[id]: { path: ws, title: '我的工作区', sessionIds: [], createdAt: now, updatedAt: now },
|
||
},
|
||
},
|
||
}
|
||
writeFileSync(file, JSON.stringify(seed, null, 2) + '\n')
|
||
const uid = await this.resolveUid(userId)
|
||
chownSync(file, uid, uid) // 实例以该 uid 运行,需可读写
|
||
chownSync(dir, uid, uid)
|
||
process.stderr.write(`[seed-workspace] ${userId} → ${ws}\n`)
|
||
} catch (err) {
|
||
process.stderr.write(
|
||
`[seed-workspace] failed for ${userId}: ${err instanceof Error ? err.message : String(err)}\n`,
|
||
)
|
||
}
|
||
}
|
||
|
||
/** Materialize the rendered patch so the dsh CLI can `--patch <file>` it.
|
||
* Per role, so a watchdog spawning alongside its main never races the file. */
|
||
private writePatch(userId: string, role: InstanceRole, patch: string): string {
|
||
const dir = join(userRoot(this.config.dataRoot, userId), 'patches')
|
||
mkdirSync(dir, { recursive: true })
|
||
const path = join(dir, `${role}.yml`)
|
||
writeFileSync(path, patch)
|
||
return path
|
||
}
|
||
|
||
private async baseEnv(userId: string): Promise<Record<string, string>> {
|
||
// 档案 81 · R1-④:堆上限跟随「已启用插件集合」推导出的配额(不再写死 160)
|
||
const memMb = instanceMaxMb()
|
||
const root = userRoot(this.config.dataRoot, userId)
|
||
const home = homeRoot(root)
|
||
const workspace = workspaceRoot(root)
|
||
const apiKey = await this.resolveApiKey(userId)
|
||
return {
|
||
...scrubEnv(process.env),
|
||
// HOME drives the child's directory picker default (homedir()); point it
|
||
// at the user's workspace so their folders show, not DSH's internal home.
|
||
HOME: workspace,
|
||
// DSH's own state (profiles/sessions/credentials) stays in `home`.
|
||
DSH_HOME: home,
|
||
// Shared read-only skill directory (dsh-skill-filesystem bundled layer,
|
||
// rank 600). Present only when configured.
|
||
...(this.config.bundledSkillDir !== '' ? { DSH_BUNDLED_SKILL_DIR: this.config.bundledSkillDir } : {}),
|
||
// 档案 33:复用官方权限逻辑,只改「默认档位」。dsh-base 的 cordis.patch.yml 读该 env:
|
||
// sandbox mode = DSH_PERMISSION_MODE;approval policy = (mode === 'danger-full-access' ? 'never' : 'ask')
|
||
// 本机内核 5.10 无 Landlock、bwrap 后端在平台容器内探测失败 → 内置默认 workspace-write
|
||
// 会 fail-closed 拒绝执行任何 shell(P0)。改 danger-full-access = 不在实例内再叠加沙箱,
|
||
// 隔离交由平台容器(bwrap + uid + cgroup)负责,且不再逐条弹审批确认。
|
||
// 用户仍可在实例「设置 → 权限」里用官方 UI 自行切回。运维可用同名 env 覆盖。
|
||
DSH_PERMISSION_MODE: process.env.DSH_PERMISSION_MODE ?? 'danger-full-access',
|
||
// 档案 44:**冻结基础运行时版本**,禁止用户升级 Python / pip / node 造成版本漂移。
|
||
// 已经天然成立的护栏:`/usr` 是 ro-bind → 实例内**改不了** `/usr/local/dsh-runtime/**`
|
||
// (pip 默认 install 直接被 Read-only 挡)、`npm/pnpm -g` 也失败、`$HOME/.local/bin`
|
||
// 不在实例 PATH 里(PATH 固定为 /usr/local/bin:/usr/bin:/bin)。
|
||
// 唯一剩下的缝是 **Python 的 user-site**:一旦 pip 创建 `$HOME/.local/lib/python3.12/
|
||
// site-packages`,它就会进入 sys.path 且**排在平台 site-packages 之前** → 用户装的同名包
|
||
// 会盖住平台包(2026-09-11 实测:`import packaging` 拿到用户版 26.3)。故:
|
||
// · PYTHONNOUSERSITE=1 → user-site 不进 sys.path(平台包永不被盖)
|
||
// · PYTHONUSERBASE=/usr/local/dsh-runtime/.no-user-install → `pip install --user`
|
||
// 尝试写在只读位 → **明确报权限错**(而不是"装上了却不生效")
|
||
// 引导:需要额外依赖 → `pip install --target <ws>/.pylibs` + PYTHONPATH,或自建 venv(不改平台)。
|
||
PYTHONNOUSERSITE: '1',
|
||
PYTHONUSERBASE: '/usr/local/dsh-runtime/.no-user-install',
|
||
// 档案 58(2026-09-12):给实例内所有 node 进程设「内存上限 + 编译缓存」。
|
||
//
|
||
// 为什么必须设 --max-old-space-size:实测一个刚起的实例私有内存 106~117 MiB、
|
||
// 峰值 VmHWM 206~209 MiB,而 **V8 的默认堆上限是按宿主物理内存推算的**
|
||
// (本机 1870 MiB → heap_size_limit **960 MiB**),它**感知不到 cgroup 的 MemoryMax**。
|
||
// 后果是内存真涨起来时先撞**内核 SIGKILL**(无优雅退出、无 stderr、日志查不到死因),
|
||
// 而不是 V8 自己触发 GC 并抛 JS 异常。设 256 MiB 的依据:dsh 实测老生代仅 27~30 MiB,
|
||
// 三倍余量足够;作用是把「失控上限」从 960 压到 256,让 V8 先 GC、不够就抛可诊断的 JS 错。
|
||
// 该 env 会被实例内子进程继承(用户自己跑 node 也受限),用户可 `NODE_OPTIONS= node …` 覆盖。
|
||
// 运维可用 DSH_INSTANCE_NODE_OPTIONS 整体覆盖(与 DSH_PERMISSION_MODE 同风格)。
|
||
//
|
||
// 为什么设 NODE_COMPILE_CACHE(Node 22+ 官方特性;本机 v22.23.2 实测
|
||
// `module.enableCompileCache` 为函数、可用):dsh 是 **223 个包 / 717 个 JS 文件 /
|
||
// 21.2 MB 源码**的大单体,实测私有内存里 **88 MiB 是 [anon] 段** —— 主要就是 V8
|
||
// 为这些文件即时生成的机器码(code range)。给出缓存目录后编译产物落盘复用,
|
||
// 冷启动不必每次重编译,直接削启动开销与峰值。
|
||
// 目录选 <userRoot>/home/.node-compile-cache:不放 ws(会被 ws-cleanup 按平台产物清理)、
|
||
// 不放 /tmp(实例的 /tmp 是私有的,每次重建会丢)。
|
||
// 档案 81 · R1-④:堆上限跟随配额(160 是档案 74 的旧下调值,已不再写死)
|
||
NODE_OPTIONS: withHeap(process.env.DSH_INSTANCE_NODE_OPTIONS, memMb),
|
||
NODE_COMPILE_CACHE: join(home, '.node-compile-cache'),
|
||
// 档案 76:dsh-univer-office 的 bundled Gateway 默认监听 TCP loopback,但本平台实例的 uid
|
||
// 被 nft 拒绝连 127.0.0.0/8(**连它自己 spawn 的 gateway 也连不上**)→ 该插件改用 unix
|
||
// socket。由平台 env 控制:**不设 = 插件保持原 TCP 行为**(回滚只需删掉这个 env)。
|
||
...(process.env.DSH_INSTANCE_UNIVER_SOCKET === undefined
|
||
? {}
|
||
: { UNIVER_DSH_GATEWAY_SOCKET: process.env.DSH_INSTANCE_UNIVER_SOCKET }),
|
||
// Each user's own key; omit entirely when unset so the harness reports
|
||
// "no key" instead of a header-hostile value.
|
||
...(apiKey !== null ? { DEEPSEEK_API_KEY: apiKey } : {}),
|
||
}
|
||
}
|
||
|
||
private async spawnInstance(userId: string, role: InstanceRole, folder: string, patch?: string): Promise<Instance> {
|
||
const isMain = role === 'main'
|
||
const port = isMain ? await findFreePort() : undefined
|
||
const instance: Instance = {
|
||
id: randomUUID(),
|
||
userId,
|
||
role,
|
||
folder,
|
||
port,
|
||
status: 'starting',
|
||
patch,
|
||
}
|
||
const map = role === 'main' ? this.mains : this.watchdogs
|
||
map.set(userId, instance)
|
||
|
||
// 注:曾在此预置默认工作区(seedDefaultWorkspace),但实测 dsh 启动时会按其注册表
|
||
// 不变量(order 与 table 一致性)把外部写入的条目清理掉 → 该做法无效,已移除调用
|
||
// (方法保留但不再使用,见 2026-09-11 记录)。工作区改由用户经官方 picker 自建。
|
||
|
||
const [command = 'dsh', ...args] = this.config.dshCommand
|
||
const launchArgs = ['--profile', role === 'main' ? 'web' : 'headless']
|
||
// --patch is a launcher flag and must precede any app/inner args (--host/--port
|
||
// for web, the task string for headless): dsh forwards the first unrecognized
|
||
// token onward, so a trailing --patch reaches the app as an unknown option.
|
||
// Off by default so the child boots even on older dsh versions.
|
||
if (this.config.enablePatch && patch !== undefined) {
|
||
launchArgs.push('--patch', this.writePatch(userId, role, patch))
|
||
}
|
||
if (role === 'main') {
|
||
launchArgs.push('--host', '127.0.0.1', '--port', String(port))
|
||
} else {
|
||
launchArgs.push(WATCHDOG_TASK)
|
||
}
|
||
|
||
// 档案 81 · R1-④:本实例的内存配额由「已启用插件集合」推导(算一次,传给 spawnAsUser 用)
|
||
|
||
const memMb = instanceMaxMb()
|
||
|
||
const env: Record<string, string> = {
|
||
...(await this.baseEnv(userId)),
|
||
DSHS_ROLE: role,
|
||
DSHS_HANDOFF_PATH: this.handoffPath(userId), // both roles: main writes, watchdog reads
|
||
}
|
||
if (isMain) {
|
||
env.DSHS_PORT = String(port)
|
||
}
|
||
|
||
// 档案 30:单实例保证升到 OS 层 —— spawn 前清掉该 uid 名下未纳管的残留 scope,
|
||
// 防止 portal 重启后旧 scope 变孤儿、与新实例并存(共享 profile → 会话/settings 冲突)。
|
||
if (this.config.isolationMode === 'account') {
|
||
this.cleanStaleScopes(await this.resolveUid(userId))
|
||
}
|
||
const { child, unit } = await this.spawnAsUser(userId, command, [...args, ...launchArgs], { cwd: folder, env }, role)
|
||
if (unit !== undefined) instance.unit = unit
|
||
this.trackChild(userId, instance, child)
|
||
return instance
|
||
}
|
||
|
||
/** Spawn the child inside a bwrap sandbox (mount/pid isolation) with setpriv
|
||
* uid isolation, under a systemd-run scope that enforces cgroup resource
|
||
* limits. 只读挂载 /usr /lib64 /etc,只暴露用户根目录 + 每用户独立 /tmp,
|
||
* 实例读不到宿主其他路径(含其他用户、DB、凭据)。返回 child + scope 名。 */
|
||
private async spawnAsUser(
|
||
userId: string,
|
||
command: string,
|
||
args: string[],
|
||
options: { cwd: string; env: Record<string, string> },
|
||
role: InstanceRole = 'main',
|
||
): Promise<{ child: ChildProcess; unit?: string }> {
|
||
const stdio: StdioOptions = ['ignore', 'pipe', 'pipe']
|
||
if (this.config.isolationMode !== 'account') {
|
||
return { child: spawn(command, args, { ...options, stdio }) }
|
||
}
|
||
const uid = await this.resolveUid(userId)
|
||
const root = userRoot(this.config.dataRoot, userId)
|
||
// 每用户独立 tmp(1777):bwrap 的 --tmpfs 权限是 755,dsh spill-local 需 mkdtemp /tmp
|
||
const tmpDir = join(root, 'tmp')
|
||
mkdirSync(tmpDir, { recursive: true })
|
||
chmodSync(tmpDir, 0o1777) // mkdirSync 的 mode 受 umask 掩码,显式 chmod 保证 1777
|
||
|
||
const bwrapArgs = [
|
||
'--ro-bind', '/usr', '/usr',
|
||
'--ro-bind', '/lib64', '/lib64',
|
||
// 2026-09-11 修复(实例内 AI 能力核查):合成根缺 /bin /sbin /lib 会导致
|
||
// dsh 的沙箱后端探针 execvp 失败(dsh-sandbox-local 在 Linux 优先用 bwrap,
|
||
// Landlock 需内核>=5.13,al8 为 4.19 故不可用)→ SANDBOX_UNAVAILABLE →
|
||
// 实例内 bash 工具被整体拒绝("refusing to run the command unconfined")。
|
||
// 用符号链接补齐标准布局(不扩大可见面:仅 /bin /sbin /lib 三个链接)。
|
||
'--symlink', 'usr/bin', '/bin',
|
||
'--symlink', 'usr/sbin', '/sbin',
|
||
'--symlink', 'usr/lib', '/lib',
|
||
// 2026-09-11(档案 39 · 用户要求「实例只能读到自己那份」):
|
||
// /etc 由「整体只读挂载」收窄为「空 tmpfs + 文件白名单」。
|
||
//
|
||
// 动机:整体挂载会让每个实例读走 /etc 下 576 个 others-readable 文件,其中包含
|
||
// **平台情报**——/etc/systemd/system/dsh-*.{service,path}(4)、
|
||
// /etc/nftables-dsh-egress.nft(出网护栏规则全文)、/etc/cron.d/dsh-*(2)、
|
||
// /etc/letsencrypt/renewal/*.conf。等于把平台架构与护栏策略交给每个租户。
|
||
// (凭据类 dshs.env / shadow / gshadow / sudoers 本就 600/0000,读不到,
|
||
// 本次不涉及;/usr 经审计无任何凭据。)
|
||
//
|
||
// 白名单 = 运行时实测必需的最小集(档案 39 §验证记录):
|
||
// node / dsh --version / os.userInfo / DNS / node-TLS / curl 200 全通,
|
||
// `dsh --profile web --dump-config` 收窄前后 **544 行 / 170 个 @deepseek-ai 插件逐字一致**。
|
||
// 效果:/etc 可见项 222 → 12,可读文件 576 → 26,平台情报 4/1/2/8 → 0/0/0/0。
|
||
// 注意:symlink 必须绑其 **realpath 目标到 symlink 原路径**,否则实例内是断链
|
||
//(/etc/nsswitch.conf → /etc/authselect/nsswitch.conf、/etc/localtime → /usr/share/zoneinfo/…)。
|
||
'--tmpfs', '/etc',
|
||
...(() => {
|
||
const allow = [
|
||
'/etc/ld.so.cache', // 动态链接器缓存(node 启动必需)
|
||
'/etc/ld.so.conf',
|
||
'/etc/passwd', // os.userInfo() / uid→name
|
||
'/etc/group',
|
||
'/etc/nsswitch.conf', // DNS 解析(symlink)
|
||
'/etc/hosts',
|
||
'/etc/resolv.conf',
|
||
'/etc/host.conf',
|
||
'/etc/services',
|
||
'/etc/localtime', // 时区(symlink)
|
||
'/etc/os-release',
|
||
'/etc/machine-id',
|
||
'/etc/pki/tls/certs', // CA bundle(curl / 系统 TLS)
|
||
'/etc/pki/ca-trust',
|
||
// 2026-09-11 修正(档案 39 补丁):**符号链接枢纽目录,必须整体挂**。
|
||
// 漏掉它的后果(实测):`/usr/bin/python3 -> /etc/alternatives/python3 -> /usr/bin/python3.6`,
|
||
// 中间一跳在 /etc → 沙箱内断链 → **21 个命令静默失效**:python3 / python / pip3 / pip-3 /
|
||
// pydoc3 / python3-config / pyvenv-3 / easy_install-3 / unversioned-python / ld(→ld.bfd) /
|
||
// pax / print-* (lp,lpr,lpq,lprm,lpstat,cancel) / ifup / ifdown / lpc。
|
||
// 其中 `python3`、`pip3`(装技能依赖)、`ld`(node-gyp 编译原生模块)都是我方场景的关键命令。
|
||
// 该目录 33 项**全部指向 /usr 下的程序/man**(已只读挂载)→ **不含任何凭据或平台情报**,
|
||
// 挂它不扩大实质可见面,只是让 /usr 里已有程序的软链重新生效。
|
||
// ⚠️ 通用教训:`/etc` 白名单化时,必须用探测器找出**所有穿过 /etc 的符号链接**,逐个覆盖:
|
||
// find /usr/bin /usr/sbin /usr/libexec /usr/local/bin -maxdepth 1 | while read f; do
|
||
// [ -L "$f" ] || continue; c=$f; n=0
|
||
// while [ -L "$c" ] && [ $n -lt 10 ]; do t=$(readlink "$c")
|
||
// case $t in /*) c=$t;; *) c=$(dirname "$c")/$t;; esac
|
||
// case $c in /etc/*) echo "$f -> $c";; esac; n=$((n+1)); done
|
||
// done | sort -u
|
||
'/etc/alternatives',
|
||
// 2026-09-11 修正(同 alternatives 一类,**第二次踩**):**`/etc/ssl` 必须挂**。
|
||
// 漏掉它的后果(guest 会话实证):宿主上 `/etc/ssl/certs/ca-bundle.crt` 存在,
|
||
// 但实例内 `/etc/ssl` 不存在 → **Python(走 FHS 默认 CA 路径)found 不到 CA** →
|
||
// `URLError(SSLCertVerificationError: self-signed certificate in certificate chain)`。
|
||
// curl 之所以一直没事,是因为它读 `/etc/pki/tls/certs/ca-bundle.crt`(也在白名单里)。
|
||
// `/etc/ssl` 只含 CA 证书(公开信息),**不含凭据/平台情报**,挂它不扩大实质可见面。
|
||
// ⚠️ 判定准则再强调:**除"叶子文件"外,还要覆盖"整个目录类枢纽"**
|
||
// (`/etc/alternatives` 是软链枢纽、`/etc/ssl` 是 CA 根目录),否则会静默打断一整类工具。
|
||
'/etc/ssl',
|
||
]
|
||
const out: string[] = []
|
||
for (const p of allow) {
|
||
let src = p
|
||
try {
|
||
src = realpathSync(p) // symlink → 解析到真实文件
|
||
} catch {
|
||
continue // 该机器上不存在则跳过(--ro-bind-try 语义)
|
||
}
|
||
out.push('--ro-bind-try', src, p)
|
||
}
|
||
return out
|
||
})(),
|
||
'--dev', '/dev', '--proc', '/proc',
|
||
'--bind', tmpDir, '/tmp',
|
||
'--bind', root, root,
|
||
// 2026-09-11(档案 38 P0 修复,档案 40 收尾):**共享技能层必须真的挂进命名空间**。
|
||
// `@deepseek-ai/dsh-skill-filesystem` 是在**实例内** `resolve(config.bundledSkillDir ??
|
||
// process.env.DSH_BUNDLED_SKILL_DIR)` 之后**直接读盘**(lib/index.js:84,181),不是编排器
|
||
// 推数据 → 光注入 env 没用,目录必须可见。此前只注入了 env 未挂载 → 档案 10/11 的共享
|
||
// 技能层「投了也发现不了」(实例内 ls = No such file or directory)。
|
||
//
|
||
// 权限**不扩大**(2026-09-11 以平台原样参数 + 本行实测):
|
||
// · mountinfo = `…/bundled-skills → 同名 ro,nosuid,nodev` → **精确叶子路径 + 只读**
|
||
// · `ls …/users/` **只列用户自己那一个**;读 DB = No such file;`touch` 挂载点 = Read-only
|
||
// · 对照组(不加本行)里父目录同样存在(bwrap 为用户 root 合成的),**不是本行新暴露的**
|
||
// ⛔ 绝不可写成挂父目录(`--ro-bind <dataRoot> <dataRoot>`)= 一次性把全部用户 home +
|
||
// DB + 凭据放进每个实例。改完必须逐字复核 args 并重跑可见面实测。
|
||
// 必须放在 '--bind root root' **之后**(bwrap 后写覆盖前写)。
|
||
...(this.config.bundledSkillDir !== ''
|
||
? (['--ro-bind-try', this.config.bundledSkillDir, this.config.bundledSkillDir] as string[])
|
||
: []),
|
||
// 2026-09-11(档案 18 v3 收尾 / 档案 17 §P1):平台策略文件在实例内**只读**。
|
||
// 威胁模型:用户可把 <userRoot>/home/profiles/web 加为工作区,随后用 bash 直接改写
|
||
// cordis.patch.yml(去掉平台段 → 恢复全盘 picker)或 package.json(挂任意 bundle)→
|
||
// 属"绕过平台策略"(跨租户仍不成立,uid/bwrap 隔离不变)。
|
||
// 只读三个文件;**不动 cordis.yml**——实测 dsh 启动时会写它(01:16:22),ro 会导致启动异常。
|
||
// 注意:必须放在 '--bind root root' **之后**(bwrap 后写覆盖前写)。
|
||
...(() => {
|
||
const profileDir = join(homeRoot(root), 'profiles', role === 'main' ? 'web' : 'headless')
|
||
const protectedFiles = ['cordis.patch.yml', 'package.json', 'pnpm-lock.yaml']
|
||
const out: string[] = []
|
||
for (const name of protectedFiles) {
|
||
const file = join(profileDir, name)
|
||
out.push('--ro-bind-try', file, file)
|
||
}
|
||
return out
|
||
})(),
|
||
'--unshare-pid',
|
||
'--chdir', options.cwd,
|
||
'--', 'setpriv', '--reuid', String(uid), '--regid', String(uid), '--clear-groups', command, ...args,
|
||
]
|
||
|
||
const unit = `dsh-${uid}-${randomUUID().slice(0, 8)}`
|
||
// 档案 81 · R1-④:cgroup 上限由「已启用插件集合」推导(原先写死 384M;见文件头注释的事故)
|
||
const memMb = instanceMaxMb()
|
||
// 档案 58(2026-09-12):MemoryMax 512M → 384M。
|
||
// 依据(全部来自实测,见 scripts/probe-instance-mem.cjs):
|
||
// · dsh 自身私有内存稳态 106~117 MiB、峰值约 145 MiB(= VmHWM 206 减去共享的 62 MiB
|
||
// node 运行时页);两个不同用户冷启动同量 → 这是**基座成本**而非用户行为。
|
||
// · 384 - 145 = **239 MiB 留给用户任务**(python / ffmpeg / pip 编译原生模块),
|
||
// 覆盖绝大多数场景;而同样的重任务在 512 下也大概率超限,故不是本次新引入的风险。
|
||
// 作用范围要看清:MemoryMax 是**上限而非预留**(systemd 不预扣),所以它**不决定并发数**
|
||
// (并发由宿主 2 核 / 1870 MiB 与实时 available 决定);降它的真实收益是
|
||
// **收窄单实例失控(内存泄漏/重任务)时的破坏半径**,给同宿主其他实例留活路。
|
||
// 若实例被 cgroup OOM kill:该实例的 scope 会非 0 退出 → 编排器按崩溃退避重启(档案 20),
|
||
// 并写结构化日志;只影响该租户,不波及其他实例(uid + cgroup 隔离)。
|
||
// 回滚:把 384M 改回 512M,npm run build,drain + 重启 dshs。
|
||
// 配套:baseEnv 里的 NODE_OPTIONS=--max-old-space-size=256 才是**主动**让 V8 提前 GC
|
||
// 的那一层;本行是兜底硬限。两者一起看才有意义。
|
||
const sdArgs = [
|
||
'--scope', '--unit', unit,
|
||
'-p', 'MemoryHigh=' + instanceBaseMb() + 'M', // **基础**(软限,超过即回收/限速)
|
||
'-p', 'MemoryMax=' + memMb + 'M', // **上界**(硬限,越界 OOM)
|
||
'-p', 'CPUQuota=150%',
|
||
'-p', 'TasksMax=128',
|
||
'--', 'bwrap', ...bwrapArgs,
|
||
]
|
||
return { child: spawn('systemd-run', sdArgs, { ...options, stdio }), unit: `${unit}.scope` }
|
||
}
|
||
|
||
/** 等待 dsh web 在 stdout 打印 launch token(最多 timeoutMs),用于拼装可直达的打开 URL。 */
|
||
private async waitForLaunchToken(instance: Instance, timeoutMs = 20000): Promise<void> {
|
||
const deadline = Date.now() + timeoutMs
|
||
while (instance.launchToken === undefined && Date.now() < deadline) {
|
||
if (instance.status === 'crashed' || instance.status === 'stopped') return
|
||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||
}
|
||
}
|
||
|
||
private trackChild(userId: string, instance: Instance, child: ChildProcess): void {
|
||
this.children.set(instance.id, child)
|
||
child.on('spawn', () => {
|
||
instance.status = 'running'
|
||
instance.pid = child.pid ?? undefined
|
||
// 稳定判定(档案 20):连续运行达 crashStableMs 视为已恢复 → 重置退避步数。
|
||
if (instance.role === 'main') this.armStableReset(userId, instance)
|
||
if (instance.role === 'main' && instance.port !== undefined && this.portGuard !== undefined) {
|
||
try {
|
||
this.portGuard.install(instance.port)
|
||
} catch (error) {
|
||
// Fail closed: without the port guard a co-tenant could reach this
|
||
// DSH's loopback RPC directly. Kill the just-spawned child and mark
|
||
// the instance crashed rather than serve unguarded.
|
||
instance.status = 'crashed'
|
||
instance.lastError = error instanceof Error ? error.message : String(error)
|
||
child.kill('SIGKILL')
|
||
}
|
||
}
|
||
})
|
||
child.stdout?.on('data', (chunk: Buffer) => {
|
||
const text = chunk.toString()
|
||
process.stdout.write(text)
|
||
if (instance.role === 'main' && instance.launchToken === undefined) {
|
||
const m = /dsh web: http:\/\/127\.0\.0\.1:\d+\/\?token=([A-Za-z0-9_-]+)/.exec(text)
|
||
if (m !== null && m[1] !== undefined) instance.launchToken = m[1]
|
||
}
|
||
})
|
||
let stderrTail = ''
|
||
child.stderr?.on('data', (chunk: Buffer) => {
|
||
stderrTail = (stderrTail + chunk.toString()).slice(-2048)
|
||
// Also surface the child's stderr on the orchestrator's own stderr so a
|
||
// boot crash is visible in journald instead of only in lastError.
|
||
process.stderr.write(`[dsh-child ${instance.role}] ${chunk.toString()}`)
|
||
})
|
||
child.on('error', (err) => {
|
||
instance.status = 'crashed'
|
||
instance.lastError = err.message
|
||
this.children.delete(instance.id)
|
||
})
|
||
child.on('exit', (code) => {
|
||
instance.exitCode = code ?? undefined
|
||
this.children.delete(instance.id)
|
||
// Release the loopback port guard as soon as the main's process is gone
|
||
// (explicit stop, restart, and crash all funnel through this handler).
|
||
if (instance.role === 'main' && instance.port !== undefined) {
|
||
this.portGuard?.remove(instance.port)
|
||
}
|
||
const map = instance.role === 'main' ? this.mains : this.watchdogs
|
||
// Only act if this instance is still the current one (avoid a stale
|
||
// exit handler touching a freshly restarted instance).
|
||
if (map.get(userId)?.id !== instance.id) return
|
||
if (instance.status === 'stopped') {
|
||
map.delete(userId)
|
||
return
|
||
}
|
||
// A one-shot watchdog that finished cleanly is not restarted.
|
||
if (instance.role === 'watchdog' && code === 0) {
|
||
instance.status = 'stopped'
|
||
map.delete(userId)
|
||
return
|
||
}
|
||
instance.status = 'crashed'
|
||
instance.lastError = stderrTail.slice(-500) || undefined
|
||
if (instance.role === 'main') {
|
||
void this.spawnWatchdog(userId)
|
||
this.scheduleCrashRestart(userId, instance)
|
||
} else {
|
||
map.delete(userId)
|
||
}
|
||
})
|
||
}
|
||
|
||
/** 崩溃策略参数(均来自 ServerConfig,可用 env 覆盖)。 */
|
||
private crashPolicy(): CrashPolicyConfig {
|
||
return {
|
||
baseDelayMs: this.config.restartBackoffMs,
|
||
maxDelayMs: this.config.restartBackoffMaxMs,
|
||
windowMs: this.config.crashWindowMs,
|
||
maxRestartsInWindow: this.config.crashMaxRestarts,
|
||
stableResetMs: this.config.crashStableMs,
|
||
}
|
||
}
|
||
|
||
/** 档案 78:熔断冷却参数(均来自 ServerConfig,可用 env 覆盖)。 */
|
||
private breakerPolicy(): BreakerPolicy {
|
||
return {
|
||
baseCooldownMs: this.config.crashBreakerCooldownMs,
|
||
maxCooldownMs: this.config.crashBreakerMaxCooldownMs,
|
||
}
|
||
}
|
||
|
||
/** 结构化自愈日志(stderr → journald,可 grep `event=instance-restart`)。 */
|
||
private crashLog(payload: Record<string, unknown>): void {
|
||
process.stderr.write(`[crash-restart] ${JSON.stringify(payload)}\n`)
|
||
}
|
||
|
||
/**
|
||
* 档案 78:熔断**告警**(原来只写一行 stderr,等于没有告警)。
|
||
*
|
||
* 双通道:① stderr → journald(`journalctl -u dshs | grep crash-breaker`);
|
||
* ② 追加 `/var/log/dsh-crash-breaker.log`(与其它巡检日志同习惯,便于定时巡检 / `tail`)。
|
||
* **只告警、不处置** —— 要不要人工介入由人判断;平台只保证「有痕迹、可 grep、冷却有界」。
|
||
* 写文件失败不影响主流程(best-effort)。
|
||
*/
|
||
private alertBreaker(payload: Record<string, unknown>): void {
|
||
process.stderr.write(`[crash-breaker] ${JSON.stringify(payload)}\n`)
|
||
const logPath = process.env.DSH_CRASH_BREAKER_LOG ?? '/var/log/dsh-crash-breaker.log'
|
||
try {
|
||
appendFileSync(logPath, `${new Date().toISOString()} ${JSON.stringify(payload)}\n`)
|
||
} catch {
|
||
/* 无权限 / 路径不存在不致命:stderr 那条仍在 */
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 档案 78:冷却期内拒绝隐式启动;冷却已过则清掉熔断态、放行一次干净尝试。
|
||
*
|
||
* **刻意不放进 `resetCrashState()`** —— 那个函数被显式启动调用,而熔断态必须
|
||
* **跨轮存活**,否则又回到「熔断即清预算 ⇒ 无限重来」。
|
||
*/
|
||
private assertBreakerClosed(userId: string): void {
|
||
const b = this.breaker.get(userId)
|
||
if (b === undefined) return
|
||
const cfg = this.breakerPolicy()
|
||
const now = Date.now()
|
||
if (breakerActive(b, now, cfg)) {
|
||
const until = breakerUntil(b, cfg)
|
||
this.crashLog({
|
||
event: 'crash-breaker-reject',
|
||
userId,
|
||
opens: b.opens,
|
||
cooldownUntil: until,
|
||
retryAfterMs: until - now,
|
||
})
|
||
throw new CrashBreakerOpenError(userId, until, b.opens)
|
||
}
|
||
this.breaker.delete(userId)
|
||
this.crashLog({ event: 'crash-breaker-cooldown-expired', userId, opens: b.opens })
|
||
}
|
||
|
||
/** 清掉熔断态(平台自身显式操作:插件启用/隔离、admin 重启)。 */
|
||
private clearBreaker(userId: string, reason: string): void {
|
||
if (this.breaker.delete(userId)) {
|
||
this.crashLog({ event: 'crash-breaker-cleared', userId, reason })
|
||
}
|
||
}
|
||
|
||
/** 档案 78 观测面:当前用户的熔断状态(无则 null)。 */
|
||
breakerInfo(userId: string): { opens: number; openedAt: number; cooldownUntil: number } | null {
|
||
const b = this.breaker.get(userId)
|
||
if (b === undefined) return null
|
||
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): { baseMb: number; memMb: number; heapMb: number } | null {
|
||
try {
|
||
const memMb = instanceMaxMb()
|
||
return { baseMb: instanceBaseMb(), memMb, heapMb: heapMbFor(memMb) }
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
/** 清空某用户的崩溃计数(显式启动 / 人工重启 / 停机时调用)。 */
|
||
private resetCrashState(userId: string): void {
|
||
this.crashHistory.delete(userId)
|
||
this.crashStreak.delete(userId)
|
||
const stable = this.stableTimers.get(userId)
|
||
if (stable !== undefined) {
|
||
clearTimeout(stable)
|
||
this.stableTimers.delete(userId)
|
||
}
|
||
}
|
||
|
||
/** main 连续运行达 crashStableMs 即视为恢复:重置退避步数(窗口历史保留)。 */
|
||
private armStableReset(userId: string, instance: Instance): void {
|
||
const previous = this.stableTimers.get(userId)
|
||
if (previous !== undefined) clearTimeout(previous)
|
||
const timer = setTimeout(() => {
|
||
this.stableTimers.delete(userId)
|
||
if (this.mains.get(userId)?.id !== instance.id) return // 已被新实例取代
|
||
if (instance.status !== 'running') return
|
||
if (this.crashStreak.delete(userId)) {
|
||
this.crashLog({ event: 'instance-stable', userId, stableMs: this.config.crashStableMs })
|
||
}
|
||
}, this.config.crashStableMs)
|
||
timer.unref()
|
||
this.stableTimers.set(userId, timer)
|
||
}
|
||
|
||
/**
|
||
* 插件加载失败自愈(2026-09-12 新增 · anysearch 事故修复)。
|
||
*
|
||
* 背景:门户「功能管理」启用插件走 `restartAndProbe` —— 探活失败会 `restoreProfile` +
|
||
* 逐个隔离 + 自动禁用不兼容插件(档案 34)。但**不经过门户的路径**(`ensure-*.cjs`
|
||
* 脚本直铺、手工改 profile)没有这一层;某个插件若与当前 dsh 版本不兼容(典型报错
|
||
* `failed to import loader entry <entry> (<pkg>)`),实例会每次启动即崩,而
|
||
* crash-restart 只止损不禁用 → 用户陷入「进不去」的死循环。
|
||
*
|
||
* 本方法补上这一层:从 `lastError` 解析出肇事的插件包名,把它从 profile 的
|
||
* `package.json` bundles 摘掉,并清掉 `cordis.patch.yml` 里引用该包的条目
|
||
* (否则 patch 中指向它的字段会报 `CONFIGURED_MISSING` 之类)。**只动 profile 的
|
||
* 文本文件,不碰 node_modules**(重装很快,回滚 node_modules 反而易碎)。
|
||
*
|
||
* @returns 摘掉的包名;未命中或修复失败返回 `undefined`
|
||
*/
|
||
private tryHealPluginFailure(userId: string, lastError: string | undefined): string | undefined {
|
||
if (lastError === undefined || lastError === '') return undefined
|
||
const m = /failed to import loader entry \S+ \(([^)]+)\)/.exec(lastError)
|
||
if (m === null) return undefined
|
||
const pkg = m[1]
|
||
// 防御:只接受合法包名形状,避免把异常文本当路径用。
|
||
if (!/^(@[a-z0-9][a-z0-9._-]*\/)?[a-z0-9][a-z0-9._-]*$/i.test(pkg)) return undefined
|
||
|
||
const dir = join(userRoot(this.config.dataRoot, userId), 'home', 'profiles', 'web')
|
||
try {
|
||
const pkgPath = join(dir, 'package.json')
|
||
if (!existsSync(pkgPath)) return undefined
|
||
const parsed = JSON.parse(readFileSync(pkgPath, 'utf8')) as {
|
||
dsh?: { profile?: { bundles?: string[] } }
|
||
}
|
||
const bundles = parsed.dsh?.profile?.bundles
|
||
if (!Array.isArray(bundles) || !bundles.includes(pkg)) return undefined
|
||
|
||
parsed.dsh!.profile!.bundles = bundles.filter((b) => b !== pkg)
|
||
writeFileSync(pkgPath, JSON.stringify(parsed, null, 2) + '\n')
|
||
|
||
// patch 里引用该包的条目一并清掉:删 `name: "<pkg>"` 行 + 紧邻在它上面的 `- id: …` 行。
|
||
const patchPath = join(dir, 'cordis.patch.yml')
|
||
if (existsSync(patchPath)) {
|
||
const lines = readFileSync(patchPath, 'utf8').split('\n')
|
||
const out: string[] = []
|
||
for (const line of lines) {
|
||
const t = line.trim()
|
||
const quotes = `name: "${pkg}"`
|
||
const single = `name: '${pkg}'`
|
||
if (t === quotes || t === single || t === `name: ${pkg}`) {
|
||
const prev = out[out.length - 1]
|
||
if (prev !== undefined && /^\s*-\s*id:/.test(prev)) out.pop()
|
||
continue
|
||
}
|
||
out.push(line)
|
||
}
|
||
writeFileSync(patchPath, out.join('\n'))
|
||
}
|
||
return pkg
|
||
} catch (err) {
|
||
process.stderr.write(
|
||
`[plugin-heal] ${userId} ${pkg} 自愈失败:${err instanceof Error ? err.message : String(err)}\n`,
|
||
)
|
||
return undefined
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 崩溃后的自动重启决策(档案 20 · 方案 A):
|
||
* 指数退避(base → max)+ 窗口熔断(窗口内超过上限则停止自动重启并标记 failed)。
|
||
*/
|
||
private scheduleCrashRestart(userId: string, instance: Instance): void {
|
||
const cfg = this.crashPolicy()
|
||
const now = Date.now()
|
||
|
||
// ── 插件加载失败自愈(2026-09-12 新增 · anysearch 事故修复)─────────────
|
||
// 走门户启用时,探活失败会回滚 profile 并自动禁用不兼容插件(档案 34)。但
|
||
// **不经过门户的路径**(`ensure-*.cjs` 直铺、手工改 profile)没有这层保护:某个插件
|
||
// 若与当前 dsh 版本不兼容,实例会每次启动即崩,而 crash-restart 只止损不禁用
|
||
// → 用户陷入「进不去」的死循环(2026-09-12 admin 实例实测)。
|
||
// 这里在重启决策**之前**先尝试摘掉肇事插件;摘掉成功就清空崩溃计数,给一次干净重启。
|
||
const healedPlugin = this.tryHealPluginFailure(userId, instance.lastError)
|
||
if (healedPlugin !== undefined) {
|
||
this.crashLog({ event: 'plugin-auto-disabled', userId, plugin: healedPlugin, reason: 'plugin load failure' })
|
||
this.resetCrashState(userId)
|
||
}
|
||
|
||
const history = this.crashHistory.get(userId) ?? []
|
||
const streak = this.crashStreak.get(userId) ?? 0
|
||
const decision = decideCrashAction(history, streak, now, cfg)
|
||
|
||
instance.restarts = (instance.restarts ?? 0) + 1
|
||
instance.lastCrashedAt = now
|
||
|
||
if (decision.action === 'circuit-open') {
|
||
// 熔断:停止自动重启,标记 failed;同时移除条目让用户重新 enter 时可重新 launch。
|
||
// 档案 78 修正:**不再「白送」满额预算** —— 记一次**跨轮存活**的熔断态,冷却期内
|
||
// 拒绝隐式启动(`assertBreakerClosed`)。原实现在这里 resetCrashState() 把窗口历史
|
||
// 一并清空 ⇒ 下一次 enter 又是满额预算 ⇒ 崩溃循环可以无限重来。
|
||
instance.status = 'failed'
|
||
this.mains.delete(userId)
|
||
this.resetCrashState(userId)
|
||
const bstate = openBreaker(this.breaker.get(userId), now)
|
||
this.breaker.set(userId, bstate)
|
||
const bcfg = this.breakerPolicy()
|
||
const payload = {
|
||
event: 'crash-loop-circuit-open',
|
||
userId,
|
||
opens: bstate.opens,
|
||
cooldownMs: breakerCooldownMs(bstate.opens, bcfg),
|
||
cooldownUntil: breakerUntil(bstate, bcfg),
|
||
windowMs: cfg.windowMs,
|
||
restartsInWindow: decision.windowRestarts,
|
||
maxRestartsInWindow: cfg.maxRestartsInWindow,
|
||
restarts: instance.restarts,
|
||
lastError: instance.lastError?.slice(0, 200),
|
||
}
|
||
this.crashLog(payload)
|
||
this.alertBreaker(payload)
|
||
return
|
||
}
|
||
|
||
const nextHistory = pruneHistory(history, now, cfg.windowMs)
|
||
nextHistory.push(now)
|
||
this.crashHistory.set(userId, nextHistory)
|
||
this.crashStreak.set(userId, streak + 1)
|
||
this.crashLog({
|
||
event: 'instance-restart',
|
||
userId,
|
||
attempt: decision.attempt,
|
||
delayMs: decision.delayMs,
|
||
restartsInWindow: decision.windowRestarts,
|
||
restarts: instance.restarts,
|
||
exitCode: instance.exitCode,
|
||
lastError: instance.lastError?.slice(0, 200),
|
||
})
|
||
|
||
const timer = setTimeout(() => {
|
||
this.restartTimers.delete(userId)
|
||
void this.spawnInstance(userId, instance.role, instance.folder, instance.patch)
|
||
}, decision.delayMs)
|
||
timer.unref()
|
||
this.restartTimers.set(userId, timer)
|
||
}
|
||
|
||
/** 档案 30:清掉指定 uid 名下的残留 systemd scope(孤儿)。严格前缀匹配,不误伤门户自身。 */
|
||
private cleanStaleScopes(uid: number): void {
|
||
this.stopScopesByPrefix(`dsh-${uid}-`)
|
||
}
|
||
|
||
/** 档案 30:portal 启动时清一次 —— 编排器重启后无法接管已有 scope,统一清掉防孤儿。 */
|
||
private cleanAllStaleScopes(): void {
|
||
this.stopScopesByPrefix('dsh-', /^dsh-\d+-[0-9a-f]+\.scope$/)
|
||
}
|
||
|
||
/** 停止匹配前缀(可选正则)的 systemd scope;list/stop 失败一律静默跳过。 */
|
||
private stopScopesByPrefix(prefix: string, re?: RegExp): void {
|
||
try {
|
||
const out = execFileSync('systemctl', ['list-units', '--type=scope', '--no-legend', '--plain'], { encoding: 'utf8', timeout: 10000 })
|
||
for (const line of out.split('\n')) {
|
||
const name = line.trim().split(/\s+/)[0]
|
||
if (name === undefined || name === '') continue
|
||
if (!name.startsWith(prefix) || !name.endsWith('.scope')) continue
|
||
if (re !== undefined && !re.test(name)) continue
|
||
try { execFileSync('systemctl', ['stop', name], { timeout: 10000 }) } catch { /* ignore */ }
|
||
}
|
||
} catch { /* list-units 失败:跳过 */ }
|
||
}
|
||
|
||
private killInstance(userId: string, instance: Instance): void {
|
||
instance.status = 'stopped'
|
||
// bwrap 沙箱模式下,scope 由 systemd 管理:systemctl stop 才能正确终止整个
|
||
// scope(含 bwrap + dsh),child.kill 只杀 systemd-run 进程、不杀 scope 内进程。
|
||
if (instance.unit !== undefined) {
|
||
try {
|
||
execFileSync('systemctl', ['stop', instance.unit], { timeout: 10000 })
|
||
} catch {
|
||
// systemctl stop 失败时走 child.kill 兜底
|
||
}
|
||
}
|
||
const child = this.children.get(instance.id)
|
||
if (child === undefined) return
|
||
child.kill('SIGTERM')
|
||
const killer = setTimeout(() => {
|
||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL')
|
||
}, 5000)
|
||
killer.unref()
|
||
}
|
||
}
|