DSH 上游 issue #8 草案 · 2026-09-30
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 自己的模块里。
结论先行:这不是"列表慢",是起子进程本身慢;列表只是最常起子进程的那条路,所以最先暴露。
| 项 | 内容 |
|---|---|
| 缺陷 | 每次 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
PROFILE_SELFTEST verdict=PASS samples=5248 top=runScriptInThisContext:97%,(garbage collector):3%同一台机器、同一个内核版本、同一天,只改"这一臂在干什么"。归因一律用 inclusive(包含)耗时。
| 臂 | 实例 | 会话数 | 这一臂在干什么 | 进程态 / CPU |
|---|---|---|---|---|
| A 空闲 | user-e alpha | 5136 | 什么都不干 | Sl / 8.8% |
| B 列表在飞 | user-e alpha | 5136 | 连发 6 次 session/list | (同进程) |
| 函数 | 位置 | A 空闲 | B 列表在飞 | 增量 |
|---|---|---|---|---|
spawn | dsh-subprocess-local/lib/index.js:1356 | 0.3%(80 ms) | 16.8%(7,626 ms) | +16.5 pp |
probeLinuxScope | :73 | 0.1%(43 ms) | 9.0%(4,096 ms) | +8.9 pp |
spawnSync(阻塞) | node:child_process:866 | ≈0 | self 9.0%(4,074 ms) | +9.0 pp |
spawnSubprocess | runner-launch-*.js:1020 | ≈0 | 7.7%(3,488 ms) | +7.7 pp |
resolveGenerationInDirectory | session-persistence/index.js:3327 | 0.3% | 0.9% | +0.6 pp |
readGenerationHeader | :3059 | 0.9% | 0.6% | ≈持平 |
readFirstZstdLine / listArtifacts | :3294 / :3031 | 0.2% / 0.2% | 0.3% / 0.1% | ≈持平 |
spawn。也就是说:这条路径的成本中心在"起进程",不在"读数据"。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,都不会阻塞事件循环。不是"再开一张显得完整",而是它和 #7 的病灶、修法、影响面都不同。
| #7(session/list 永不返回) | 本件(spawn 阻塞探测) | |
|---|---|---|
| 病灶模块 | dsh-session-persistence-jsonl + dsh-subprocess-local 的 /proc 轮询 | dsh-subprocess-local 的 spawn 路径 |
| 触发条件 | 有受管子进程在飞(/proc 全表轮询每 15 ms) | 任何一次 spawn(不需要特殊条件) |
| 修法 | 组成员判定改定点 + 轮询加退避 | 能力探测进程级缓存 + 异步化 |
| 影响面 | 会话列表(以及所有需要事件循环的请求) | 所有起子进程的功能 |
| 单独观测 | 需要构造"有子进程在飞"的现场 | 空闲实例上连发列表即可复现(本页 B 臂) |
两条修法都不改变语义,只改变"什么时候付这笔钱"。
probeLinuxScope/probeLinuxManager 的结果在进程内 memo(含失败结果与过期策略),不要让每次 spawn 都问一遍 systemd。spawnSync 换成异步探测 + 首用前 await(或在进程启动时预热),避免在请求处理路径上停摆事件循环。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 的饱和臂)。以下是我们没有证明的,提交前请以上游复核为准。
linuxDeepProbePassed 与 probeLinuxManager() 哪一条在重复付钱,我们只证明了"这笔钱在 spawn 路径上被反复付",没有逐分支验证。app-[version](node v24.16.0)上测过;stable / master 通道未测(同源代码,理应一并受影响)。