← 回到 infra 主页

DSH 上游 issue #8 草案 · 2026-09-30

每次起子进程都阻塞地探一次 systemd scope:spawn 自耗 0.3% → 16.8%

在 dsh-subprocess-local 里,每一次 spawn 都会先走 selectContainmentMode() → probeLinuxScope(),而后者用阻塞的 spawnSync 跑一整轮 systemd-run --user --scope … systemctl --user show … 只为了问一句「当前用户 manager 支持不支持 scope」。 带 JS 符号的 CPU profile 实测:把 6 次 session/list 压进 45 秒窗口后, spawn 的 inclusive 从空闲时的 0.3% 涨到 16.8%,其中 spawnSync 自耗 9.0%(4.07 秒)—— 那 4 秒里事件循环完全停摆。这不是我们的配置问题,缺陷在 DSH 自己的模块里。

一句话结论与优先级

结论先行:这不是"列表慢",是起子进程本身慢;列表只是最常起子进程的那条路,所以最先暴露。

第 1 节 · 症状与影响面
项内容
缺陷每次 spawn 都付一发阻塞式能力探测;探测结果本可进程级缓存,实测未生效
直接后果session/list(5136 会话)在健康实例上仍需 3.6–4.4 秒(6/6 次),其中约 1/6 的窗口时间花在 spawn 上
影响面凡是要起子进程的功能:会话列表、工具执行、workflow/subagent 启动、终端类工具等 —— 不止 session/list
优先级高 它是一个**全局放大系数**:每次 spawn 的固定成本,会乘到所有子进程密集的功能上
与 issue #7 的关系#7 是 session/list 永不返回;本件是 spawn 路径的独立缺陷。两者叠加才导致"零字节":#7 让列表排不上队,本件让列表反复停摆。修好本件,#7 的现场仍会卡;修好 #7 现场而不修本件,列表依然要 3.6–4.4 秒。

环境与测量方法

用 SIGUSR1 进运行中的 Node inspector 取带 JS 符号的 CPU profile —— 不重启现场、不注入代码。

Host: [host], 256 logical cores, Linux. 观测时刻 2026-09-30 08:0x +08:00
Server: node [app-dir]/app-[version]/node_modules/.bin/dsh --profile infra
        --host 127.0.0.1 --port 4207 --trusted-host <host>   (user-e alpha 实例, PID 14840, node v24.16.0)
Sampler: Profiler.setSamplingInterval = 500 µs,窗口 45 s,72,497 个样本
取法: node ops/dsh/profile-dsh-node.mjs --pid <PID> --seconds 45 --out <file>.cpuprofile
尺子先自证(阴性对照:拿一个真在烧 CPU 的子进程,profile 里必须出现它的函数名): PROFILE_SELFTEST verdict=PASS samples=5248 top=runScriptInThisContext:97%,(garbage collector):3%

证据:三臂对照 + inclusive 归因

同一台机器、同一个内核版本、同一天,只改"这一臂在干什么"。归因一律用 inclusive(包含)耗时。

第 3.1 节 · 三臂(会话数、进程态、CPU 为同时刻现读)
臂实例会话数这一臂在干什么进程态 / CPU
A 空闲user-e alpha5136什么都不干Sl / 8.8%
B 列表在飞user-e alpha5136连发 6 次 session/list(同进程)
第 3.2 节 · 逐函数 inclusive:B 臂相对 A 臂的增量(45.3 s 窗口)
函数位置A 空闲B 列表在飞增量
spawndsh-subprocess-local/lib/index.js:13560.3%(80 ms)16.8%(7,626 ms)+16.5 pp
probeLinuxScope:730.1%(43 ms)9.0%(4,096 ms)+8.9 pp
spawnSync(阻塞)node:child_process:866≈0self 9.0%(4,074 ms)+9.0 pp
spawnSubprocessrunner-launch-*.js:1020≈07.7%(3,488 ms)+7.7 pp
resolveGenerationInDirectorysession-persistence/index.js:33270.3%0.9%+0.6 pp
readGenerationHeader:30590.9%0.6%≈持平
readFirstZstdLine / listArtifacts:3294 / :30310.2% / 0.2%0.3% / 0.1%≈持平
请特别注意最后三行。直觉会以为"列 5136 个会话贵在读文件、解 zstd、排序"——实测这三项合计不到 2%,且两臂持平。 真正涨上来的是 spawn。也就是说:这条路径的成本中心在"起进程",不在"读数据"。
`(idle)` 占比从 A 臂的 90.3% 掉到 B 臂的 44.0%;差额基本就是上面这几行。 session/list 自身耗时 6/6 次实测 3.63 / 4.06 / 3.81 / 4.35 / 4.43 / 3.99 秒(返回 1,992,408 字节)。

代码指针与调用链

调用链是 profile 直接给出的(自底向上),不是看源码推的。

spawn  ← spawnSync  ← probeLinuxScope@dsh-subprocess-local/lib/index.js:73
       ← selectContainmentMode@index.js:1384
         ← apply@index.js:118
           ← spawn@index.js:1356
             ← executeArgv@index.js:157
function probeLinuxScope(internals = {}) {                      // :73
  const unitBase = unitStem("dsh-subprocess-probe");
  const result = (internals.spawnSync ?? spawnSync)(internals.systemdRun ?? "systemd-run", [
    "--user", "--scope", "--quiet", "--collect", "--expand-environment=no",
    `--unit=${unitBase}`,
    "--", internals.systemctl ?? "systemctl", "--user",
    "show", `${unitBase}.scope`, "--property=ActiveState", "--value"
  ], {
    env: quietSystemdEnvironment(),
    stdio: "ignore",
    timeout: SYSTEMCTL_TIMEOUT_MS      // ← 同步等一轮 systemd 往返,期间事件循环不动
  });
  ...
}

selectContainmentMode(kind) {                                    // :1384
  const platform = this.internals.platform ?? process.platform;
  let fallbackReason;
  if (platform === "linux") {
    const available = this.linuxDeepProbePassed ? probeLinuxManager() : probeLinuxNative();
    if (available) this.linuxDeepProbePassed = true;            // ← 有缓存位……
    if (available) return "linux-scope";
    ...
}
要向上游问清的两点:① 明明有 linuxDeepProbePassed 这个缓存位,为什么"列表在飞"时仍在反复付 probeLinuxScope 的钱 —— 是缓存没覆盖到这条路径,还是 probeLinuxManager() 自身也贵?我们没有分离验证(见诚实清单)。 ② 即便缓存生效,能力探测是否必须在 spawn 的同步路径上?把它挪到进程启动时一次性完成,或用异步探测 + 首用前 await,都不会阻塞事件循环。

为什么它值得单独一张 issue

不是"再开一张显得完整",而是它和 #7 的病灶、修法、影响面都不同。

第 5 节 · 与 #7 的边界
#7(session/list 永不返回)本件(spawn 阻塞探测)
病灶模块dsh-session-persistence-jsonl + dsh-subprocess-local 的 /proc 轮询dsh-subprocess-local 的 spawn 路径
触发条件有受管子进程在飞(/proc 全表轮询每 15 ms)任何一次 spawn(不需要特殊条件)
修法组成员判定改定点 + 轮询加退避能力探测进程级缓存 + 异步化
影响面会话列表(以及所有需要事件循环的请求)所有起子进程的功能
单独观测需要构造"有子进程在飞"的现场空闲实例上连发列表即可复现(本页 B 臂)
若把它并入 #7,风险是它被按 #7 的标题范围("列表接口")修掉而漏掉 spawn 路径本身。因此单列,并在两页之间互相交叉引用。

建议修法与验收判据

两条修法都不改变语义,只改变"什么时候付这笔钱"。

  1. 能力探测进程级缓存一次:把 probeLinuxScope/probeLinuxManager 的结果在进程内 memo(含失败结果与过期策略),不要让每次 spawn 都问一遍 systemd。
  2. 从同步路径上挪走:spawnSync 换成异步探测 + 首用前 await(或在进程启动时预热),避免在请求处理路径上停摆事件循环。
验收判据(可复跑,二值): ① 空闲实例连发 6 次 session/list,profile 里 spawnSync 的 self 占比应从 9.0% 降到 <1%; ② spawn 的 inclusive 应从 16.8% 降到与空闲臂同量级(<2%); ③ 端到端:5136 会话的 session/list 应从 3.6–4.4 s 降到 <1.5 s(同机同数据)。 阴性对照:若修完 ③ 达标但 ① 不达标,说明探测被挪到了别处,仍会在冷启动付一次 —— 应予记录而非当作通过。

复跑命令与产物

# 1) 取 profile(只发一个 SIGUSR1 开 inspector,不注入代码、不改目标状态)
sudo -n -u user-k sudo -n node ops/dsh/profile-dsh-node.mjs --pid <PID> --seconds 45 --out /tmp/x.cpuprofile

# 2) inclusive 归因 + 定向 grep
node ops/dsh/profile-dsh-node.mjs --analyze /tmp/x.cpuprofile --top 12
node ops/dsh/profile-dsh-node.mjs --analyze /tmp/x.cpuprofile --grep 'spawn|probeLinuxScope|readGenerationHeader'

# 3) 尺子自证(阴性对照)
node ops/dsh/profile-dsh-node.mjs --selftest

# 4) 发真列表(<token> 取该户 web.log 末条 "dsh web:";<port> 取该户 DSH_WEB_PORT)
curl -sS -L -c /dev/shm/c.ck -o /dev/null "http://127.0.0.1:<port>/?token=<token>"
curl -sS -b /dev/shm/c.ck -o /dev/null -w '%{http_code} %{time_total} %{size_download}\n' \
  -X POST -H 'content-type: application/json' -H 'origin: http://127.0.0.1:<port>' \
  --data '{"type":"client-request","rpcId":"p","method":"session/list","payload":{"args":{"_request":{}}}}' \
  http://127.0.0.1:<port>/api/session/list
产物:[storage]/…/user-b-profile-20260930/user-e-idle.cpuprofile(52,365 样本,A 臂)、user-e-list.cpuprofile(72,497 样本,B 臂)、user-b.cpuprofile(52,105 样本,#7 的饱和臂)。

诚实清单

以下是我们没有证明的,提交前请以上游复核为准。

  1. 没有分离验证缓存为何未生效:linuxDeepProbePassed 与 probeLinuxManager() 哪一条在重复付钱,我们只证明了"这笔钱在 spawn 路径上被反复付",没有逐分支验证。
  2. 没有数出每轮列表 spawn 的确切次数:只给了时间归因(inclusive 16.8% / self 9.0%),没有给调用次数。
  3. 单机单日,只在 app-[version](node v24.16.0)上测过;stable / master 通道未测(同源代码,理应一并受影响)。
  4. 采样口径:500 µs 采样、45 s 窗口;inclusive 是"该节点及其全部后代的采样之和",与 wall-clock 阶段耗时同量级但不恒等。
  5. 未提交上游:本页是 issue 草案,尚未在上游仓库建 issue。