← 回到 infra 主页

DeepSeek Harness · Upstream · Issue #7 草案

会话列表接口 session/list 永不返回:一元 RPC 上零字节挂起 ≤300 秒,而同一实例的兄弟接口 100 毫秒就回

先说结论:这不是我们部署脚本的问题,也不是你的 token 过期、不是会话数据丢了。我们在一台跑了 16 小时的生产实例上实测到:POST /api/session/list 在 2026-09-08 08:26–08:52 之间共 10 次全部零字节超时(最长一次给了 300 秒预算),连响应头都没吐出来;而用同一个登录 cookie、同一条连接打 session/modelCatalog,回回 100 毫秒上下。更关键的一条反证:在上面那条 session/list 还挂着的时候并发打 8 次 modelCatalog,8 次全 200、首字节最快 90 毫秒 —— 说明服务端的事件循环是活的,所以"整台机器被别的任务压死"这个解释解释不通。

这份数字是活的,引用请带时间戳:本页所有实测采集于 2026-09-08 08:25–08:52(+08:00);会话总数这类数字在采集期因为并行会话写入一直在涨(2306 / 2308 / 2311 分别是三个时刻的口径),引用时必须连时刻和统计口径一起带上。
本页的证据强度分两级,请勿混用:已实测 = 有命令、有原始输出、可复跑;未定论 = 只是形状相符的候选,我们在正文里逐条标出并说明还缺哪个实验。本页刻意没有给出单一"根因定论",因为要定案还缺一次带 JS 符号的火焰图或服务端分段计时(见第 4 节)。

一句话结论与优先级

三条实测事实就足以定性,不需要先知道「根因叫什么名字」。

三条硬事实

用大白话说:DSH 网页左边那栏"历史会话列表",是靠一个叫 session/list 的接口填的。接口不返回,列表就永远是空的 —— 界面看起来"登录成功了但里面什么都没有",因为前端连"我有几个会话"都不知道。这和你的 token、跟会话文件有没有坏,都没关系。

第 1 节 · 三条硬事实(采集 2026-09-08 08:26–08:52 +08:00)
事实实测数字它排除了什么
接口完全不回10/10 次零字节;最长预算 300 秒;连响应头都没有(首字节 0.000000s)排除「只是慢」:慢也该先回响应头再慢慢传 body
兄弟接口正常modelCatalog 33–238 毫秒(8/8 全 200);settings/describe 49 毫秒;session/canOpenWorkspacePath 40 毫秒排除鉴权 / cookie / 传输层:同一 cookie 同一路径
挂着的时候事件循环还是活的挂起窗口内并发 8 次 modelCatalog:8/8 全 200,首字节 90/98/111/119/143/147/166/1122 毫秒(中位 131 毫秒)排除「服务端被同步任务彻底钉死」:如果是,兄弟请求也该一起卡

影响面与优先级

  • P0 用户可感知且不可绕过 会话列表是网页入口面;此接口不返回,整个 Web 界面对人不可用,而进程活着、鉴权正常、其它接口全通,监控看不出任何异常。
  • 失败是静默且彻底的 没有错误体、没有 4xx/5xx、没有超时、没有部分结果。客户端既拿不到错误也没有东西可重试,且它分不清「这个工作区本来是空的」和「服务端挂了」。(这也是我们上一轮把它误判成"前端没选工作区"的原因,见第 6 节的诚实交代。)
  • 成本对监控不可见 这条请求自身归因到的 CPU ≈ 0,read_bytes ≈ 0(全打 page cache),写入 socket 0 字节。任何按 CPU / IO 告警的看板都会报「健康」。
  • 不会自愈、且随数据量单调恶化 跨浏览器重启、低负载窗口、300 秒长预算都一样。而这条路径没有内存缓存也没有 TTL,每次首屏/每次切工作区都要重新走一遍全部会话目录;上游也明说没有任何东西会删会话文件(dsh-session-persistence-jsonl/README.md 已知限制原文 "Nothing deletes session files")。这台机器 7 天就长到约 2308 个会话 / 1006 MiB(约 526 个/天)。
顺带一个对我们自己有用的结论:本页同时纠正了我们昨晚的一个误判 —— 那个 Choose a workspace to start 只是输入框的占位文案,不能当成「卡住」的证据;真正卡住的是服务端这个一元 RPC。

实测证据(含正负对照)

每条都给出可复跑的命令与当刻数字。"负对照"指故意让条件变差(比如把宿主负载拉高),看现象会不会变 —— 结果是不变。

正对照:接口真的不回

TOK=$(sudo -n -u user-k sudo -n grep -o "token=[^&]*" [实例目录]/web-url.txt | cut -d= -f2)
J=/tmp/jar.txt; curl -sS -m 8 -c $J -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:4002/?token=$TOK"
# -> 303(换到 cookie),之后所有 RPC 复用该 cookie

curl -sS -m 90 -b $J -H 'Content-Type: application/json' \
  --data '{"type":"client-request","rpcId":"rt-1","method":"session/list","payload":{"args":{"_request":{}}}}' \
  -w '\ncode=%{http_code} total=%{time_total}s first=%{time_starttransfer}s size=%{size_download}\n' \
  http://127.0.0.1:4002/api/session/list
# -> code=000 total=90.001115s first=0.000000s size=0   (curl exit 28)
第 2 节 · 本实例历次 session/list 调用(采集 2026-09-08 08:26–08:52 +08:00)
预算 -m耗时首字节字节数HTTP 码
60 s60.011–60.018 s0.000000 s0000(超时)
60 s60.011–60.018 s0.000000 s0000(超时)
300 s300.312 s0.000000 s0000(超时)
30 s30.030 s0.000000 s0000(超时)
90 s90.0011 s0.000000 s0000(超时)
90 s90.0016 s0.000000 s0000(超时)

合计 10 次全部零字节(其中 7 次 -m 60、1 次 -m 300、1 次 -m 30、2 次 -m 90 为本文补测)。

session/list 与兄弟 RPC 的首字节耗时对照(对数刻度,2026-09-08 08:42–08:52 +08:00) 首字节耗时对照(对数刻度,越短越好) 10ms 100ms 1s 10s 100s 300s session/canOpenWorkspacePath 40 ms settings/describe 49 ms session/modelCatalog(挂起中并发 8 次) 中位 131 ms(最慢 1122 ms) agentPresets/list(同为扫描目录) 4.4–6.0 s session/list 10/10 次零字节,最长预算 300 s 仍未回 同一登录 cookie、同一 http://127.0.0.1:4002,2026-09-08 08:42–08:52 +0800。 红色条:连响应头都没收到(首字节 0.000000 s),因此按 300 s 预算上界画。 绿色条:正常一元 RPC;橙色条:本身也要扫目录,但仍在秒级返回。
单击图可放大,再单击缩小。刻度是对数,否则 100 毫秒和 300 秒没法画在一张图上。

关键反证:挂起期间事件循环仍可调度

这是本页最重要的一条。如果一个服务端被同步任务彻底钉死(比如在跑同步系统调用),那么所有请求都该一起卡;但我们在 session/list 还挂着的那 12.37 秒里并发打了 8 次 modelCatalog,全都正常返回:

/home/user-l/seat5/seat5_critpath.py
# list  -> TimeoutError, total 30.0302 s, first_byte None, bytes 0        (08:42:40 -> 08:43:09)
# modelCatalog while stalled -> 8/8 HTTP 200, first-byte 0.090 / 0.098 / 0.111 / 0.119 /
#                               0.143 / 0.147 / 0.166 / 1.122 s (median 0.131), 1733-1734 B
# idle baseline modelCatalog x3 -> 0.097 / 0.117 / 0.099 s              (08:42:38)
# MainThread CPU over the 30 s window      -> 25.17 s = 0.838 cores
#   ...of which the 12.37 s sub-window      ->  9.99 s = 0.807 cores  (= 与空闲同量级)
# => 该请求自身归因 CPU ≈ 0
怎么读这句话:进程一直在吃约 0.8 个核,但"把这条请求加进去"并没有让它多吃 CPU。也就是说这 0.8 核是背景消耗(第 8 节会说是谁在吃),不是这条请求在干活。一条请求既不占 CPU、又永远不回、还不阻塞兄弟请求 —— 这正是最值得上游看一眼的形状。

UI 侧因果闭环:前端确实在等这个接口,而且不止它一个挂

上一版我们只知道接口不回。这一版把「不回」和「你看到的左侧空白」之间的链条补上了:我们在真实页面里追踪浏览器自己发出的请求(不是我们手打的 curl),等 20 秒看它落什么状态。

第 2 节 · 真实页面一次首屏 + 选工作区,浏览器自己发出的 /api/* 请求(2026-09-08 09:05 +0800)
状态端点含义
200 完成session/modelCatalog、session/create、settings/describe、credentials/describe、agentPresets/list、commands/list、skills/list、dynamicCordisRunner/inventory非枚举类端点全部正常
20 秒仍未回session/list 和 subagents/list两个枚举类端点同时挂:共因在「枚举/扫描」这条路径,而不是某一个端点的孤立缺陷
未失败(requestfailed 为空)不是连接被拒/中断,是服务端一直不结算
所以左侧为什么会空:工作区与它的 82 个会话 id 关系,是由另一条通道(workspace/follow 流)在 324 毫秒内送到的,完全完好;但每一行的摘要要由 session/list 填 —— 那个 RPC 永久 pending,于是有 82 个 id 也渲染不出 82 行,侧栏只剩一个光秃秃的工作区名。
顺手纠正我们自己上一版的一个错判(留档不删):我们曾据「workspace/follow 能拿到 82 个 id」推断「侧栏不依赖 session/list」。这条是错的:id 关系与摘要行是两段数据,前者完好不能推出后者能拿到。是上面这份请求追踪把它证伪的。

客户端还有一处交互缺陷(顺手一起报)

这次真实点击还撞出第二个问题:空白首屏上点一次工作区不生效,得点第二次才打开会话。逐步取证:

第 2 节 · 三次快照(真浏览器、只做导航级点击)
步骤界面浏览器里持久化的当前会话
初始显示占位「选择一个工作区开始」dsh.sessions.current = null
第 1 次点工作区选择器标签变成 infra,但会话仍没打开仍是 null
第 2 次点工作区会话打开了,输入框可用写入 {"sessionId":"session-3617a309-…"}

代码落点:dsh-client-ui-workspace/lib/client.js 的 connectWorkspace() —— 它把「正在连接」的 promise 存进 connecting map,下一次进入直接返回那个还没落地的 promise;如果首次的 session/create 落得晚,第一次点击就没人把会话打开,界面停在"已选工作区但无会话"。

我们这次验证的副作用,如实登记:上面第 2 次点击经过 sessions.create() 真实新建了一个空会话 session-3617a309-d6e3-4ebf-b6bd-9b38970d41c1(界面上显示为一条 "New Session"),我们没有删除它(该 seam 本身也没有删除 API),请按需在界面上处理。这是为了验证交互而做的真实点击所产生,不是故障。

预期 vs 实际

第 2 节 · 契约要求与实际行为
维度上游文档/代码要求实测行为
调用形态生成式描述符 invocation: { kind: "direct" }、没有 mode: "stream",所以 gateway 走 await 一次性取值并回一个 JSON一致(信封无问题,见第 3 节第 2 项)
成本口径dsh-session-persistence-jsonl/README.md:93 原文 "Listing reads and validates only the header frame.";dsh-session-query/README.md:145 原文 "listSessions stays lightweight"进程外复刻同一语料:wall 0.92–1.00 s / CPU 1.16–1.23 s;本机 header 扫描基准 0.171 s 串行、0.092 s 8 路
响应头时机handler 解析出值就应该由 Response.json 一次性写出(handle 之前不发头)始终没有响应头 ⇒ 卡点在 handler 内部 settle 之前
失败语义坏数据是 fail-closed:包成 SESSION_QUERY_PERSISTENCE_FAILED 快速报错没有报错,就是静默不返回(也说明不是数据坏了,见第 3 节第 6 项)

已排除的原因(11 项,都实测过)

写 issue 最值钱的部分不是"我猜是这样",而是"这些我都试过了,不是它们"。以下每项都有命令或源码行号支撑。

  • 不是 token 过期 / 鉴权失败。 GET /?token=… 0.488 秒返回并下发 HttpOnly cookie;随后同一 cookie 的所有 RPC 都成功。过期 cookie 是立刻 401,绝不会零字节拖 300 秒。
  • 不是我们信封写错了。 从真实浏览器一次普通导航里抓到的原始 body(131 字节)与我们手写的逐字节一致并原样重放。参数形状不对时 assertExactArguments 是同步抛错(gateway/arguments-invalid),不是掛起;_request 也确实是唯一合法且必需的键。
  • 不是把流式/订阅语义搞混了。 该描述符 invocation.kind: "direct" 且无 mode: "stream";_request 在实现里叫 "reserved empty list request",其唯一合法字段 cursor 被 host 完全忽略。真正的流是 $events 与 SessionEventStream,都不是这个端点。
  • 不是宿主 CPU 抢不过。 宿主 256 核、约 73% 空闲;同一个零字节结果在 load1 = 10.89 / 13.94 / 14.89 / 15.11 / 16.07 / 16.40 / 18.50 / 20.39 / 34.50 九个负载读数下完全一致。我们全程没有 kill 也没有干扰那些跑着的计算任务。
  • 不是整个事件循环被饿死。 见第 2 节反证:挂起窗口内 8/8 兄弟请求在 90–1122 毫秒返回。主线程在约 100 毫秒粒度上可被调度,所以「零调度式饿死」不成立。
  • 不是数据损坏 / 文件丢失导致 fail-closed 中止。 对约 2311 个会话目录的只读普查:坏 zstd magic 0、空日志 0、缺日志 0;独立解析 2306 个 header 帧全部成功,无重复 id、无旧扁平布局。这条很重要:数据坏了会换来一次快速报错,与观测到的静默掛起正好相反。
  • 不是数据目录没挂上 / 权限读不到。 所有会话日志存在且可读;进程外复刻枚举同一棵树 ALL_OK。
  • 不是磁盘 IO 慢或在换页。 掛起期间 read_bytes ≈ 0(全打 page cache);进程 Swap 0.0、16.3 小时里 majflt = 1。
  • 不是算法复杂度问题。 列表只读每会话的第一个(header)帧,从不读事件体,因此是 O(会话数) 而不是 O(会话数 × 事件数)。readSession/readSurface/filterEvents/observeSession 这些全量读都不在这条路径上。
  • 不是 probeSmallCold 那条全量回放分支。 它被 stat(path).size > coldBlankProbeMaxBytes(本部署取默认 1024 字节)挡住,且只对「冷且无缓存行或 blank=true」的会话触发;2240 条缓存对约 2308 个会话,最多约 112 次 stat,且压缩日志都大于 1 KiB 会立即返回。
  • 不是端点级被前一条卡住的请求占满。 dsh-client-connection 里只有 socket 背压(drain),没有 per-endpoint 互斥或队列;并发对照实验也从反面证明了这点。
特别提醒上游读者:「node 进程 top 显示 100% CPU + 请求不返回」这种表象很容易被归成「CPU 竞争」,本页第 4 条与第 5 条正是为了把这条路堵掉:竞争确实存在,但它不是这条请求不返回的原因。

仍未定论的候选(禁止当结论)

这一节是我们对自己的约束:以下四条都只是「形状相符」,我们拿不到直接证据,所以不写进 issue 正文的定论里。

  1. 投影缓存 write-behind 与列表的投影列共用同一个 storage-domain 单链。我们有"链存在"(dsh-storage-domain/lib/index.js:97 原文 "every write queues on the chain, awaits backend durability",enqueue 在 :221)和"写确实在持续发生"(08:44 观测:2240 条记录,10 秒内 10 个文件 mtime 变化,60 秒内 83 个)两个证据,但没有"列表被它堵住"的直接证据。缺实验
  2. V8 堆逼近默认上限导致 GC 退化。进程 RSS = VmHWM = 4.43 GiB、未设 --max-old-space-size,支持这个假设;但线程采样里 V8Worker 只占 0.03–0.04 秒(20 秒窗口)是反证。两个证据互相矛盾,未裁决。缺实验
  3. 空闲期那次常驻 header 扫描与列表争用同一份语料。(见第 8 节)无请求在飞时它一直开着 16–23 个会话日志 fd 在滚动扫描。它至少解释了"背景 0.8 核",但是否影响列表未证。缺实验
  4. 客户端侧"自动选工作区失败后不再重试"是否另成一个 bug。我们今晚确实观察到一个真实状态机缺陷:dsh-client-ui-workspace 的 watchNavigation 只在首次做一次自动选工作区,一旦落空就 initial = "done" 不再重试;startSession() 拿不到目标时更是 sessions.clear(); return;(静默无操作)。但只要本 issue 的列表接口先修好,这个客户端问题就退化成"首屏晚一点",不足以让界面变空,所以我们把它列为次要项。次要
要定案只差两样东西(这也是我们把它交给上游的原因 —— 现场在我们手里,但改代码在上面):①node --cpu-prof / --perf-basic-prof 带 JS 符号的火焰图;②在 sessionController.list 的各个阶段(枚举 / header / 投影 / 排序 / 编码)打时间戳。当前这条代码路径没有任何分段计时,这正是我们 5 个并行取证席都无法把它归因下去的结构性原因。

2026-09-30 追加:同机同版本对照 —— 差异量是「会话在写」,不是「会话多少」

本节补上第 4 节列为「缺实验」的第 1 条候选所缺的那次对照。全部为当日现读,命令在节末。

第 4.1 节 · 同一台 [host]、同一个内核 app-[version]、同一天逐户对照(07:2x–07:3x +08:00)
户会话数近 3 分钟被写的会话文件session/list 实测
user-e513603807 ms
user-c352512839 ms
user-a184101525 ms
user-m145291418 ms
user-b221456> 240 s(超时)
会话数不是差异量。user-e 的会话数是 user-b 的 2.3 倍(5136 : 2214)却只用 3.8 秒;唯一与"卡住"同现的量,是该实例有没有会话在被持续写。空闲户的耗时对会话数是线性的(0.74–0.98 ms/行,上表四户逐户可复算)—— 按这条基线,user-b 的 2214 行本应约 2 秒,而它重启后实测 2119 ms,与基线吻合。

2. 同一个实例的退化曲线(重启可复现地"治好",然后在 ~16 分钟内复发)

第 4.2 节 · user-b alpha 实例(PID 观察窗口 07:11–07:36 +08:00)
时刻RSSCPUsession/list
重启后即刻0.34 GB23 %2119 ms
+2 分钟0.60 GB43 %—
+16 分钟1.11 GB91 %> 240 s
+24 分钟1.18 GB95 %> 280 s(0 字节)
单次 280 秒的原始观测:curl -m 280 … /api/session/list → code=000 t=280.000373 size=0,零字节、连响应头都没有,与本页第 1 节的签名逐字一致。重启只重置了累积状态,没有消除触发条件,所以约 16 分钟后原样复发。

3. 这组对照落在本页第 4 节的哪几条候选上

第 4.3 节 · 候选逐条对账
候选本组证据
① 投影缓存 write-behind 与列表投影共用 storage-domain 单链直接支持 唯一同现量就是"链上有写在排队"(56 次/3 分钟);零写入且会话更多的 user-e 完全不受影响。这正是该候选此前缺的那次对照。
② V8 堆逼近上限 / GC 退化同向但不独立 RSS 单调涨(0.34→1.18 GB)且未设 --max-old-space-size,与②相容;但②解释不了"为什么 user-e 堆更大(5136 会话)却没事"。
③ 空闲期常驻 header 扫描与列表争用同一份语料被削弱 user-b 在观测窗口内不是空闲态(3 分钟 56 次写),而它照样卡;③不足以解释本组。

4. 代码侧成本模型(与上面吻合)

session/list → list() → listArtifacts():全量枚举每一个会话产物,逐个读 generation header(zstd 首帧解压)+ 逐个 stat(dsh-session-persistence-jsonl/lib/index.js:2574-2591)。而取一个父会话的 subagent 子会话时,children() 又调一次 listArtifacts()(:2684),validateRelatedSources() 再调第二次(:2690)—— 单次父会话操作 = 多趟 O(N) 全量扫描,没有任何索引或缓存。

这解释了"为什么基线是线性、且随 N 与写churn 共同恶化":N 决定每趟扫描的长度,写 churn 决定这些扫描要跟谁抢同一条链。子会话(subagent)数量正是把 N 抬上去的东西,但它不是差异量—— 见第 4.1 节。

5. 复跑命令(只读)

# 逐户会话数 + 延迟(含超时判词)
sudo -n -u user-k sudo -n python3 ops/dsh/session-list-health.py --channel alpha

# 写 churn:某户近 3 分钟被写的会话文件数
sudo -n -u user-k sudo -n find /home/<user>/.dsh-alpha/sessions -name '*.zstd' -newermt '-3 minutes' | wc -l

# 单次列表(零字节签名;<token> 取该户 [实例目录-alpha]/web-url.txt)
curl -sS -b cookie -m 280 -o /dev/null -w 'code=%{http_code} t=%{time_total} size=%{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

2026-09-30 定案:带符号 CPU profile 把 79% 的 CPU 钉在一条循环上 —— 并消解「兄弟请求」那处不同形

本节补上第 4 节自认「要定案只差的两样东西」中的第 ① 样(带 JS 符号的火焰图),并顺带把第 ② 样(分段归因)从函数名读出来;同时回答一个此前没解释清的差异。

1. 方法:不重启现场,用 SIGUSR1 进 inspector 取 profile

node ops/dsh/profile-dsh-node.mjs --pid <PID> --seconds 30 --out <file>.cpuprofile
node ops/dsh/profile-dsh-node.mjs --analyze <file>.cpuprofile --top 6

# 尺子先自证(阴性对照:拿一个真烧 CPU 的子进程,必须看到它的函数名)
node ops/dsh/profile-dsh-node.mjs --selftest
  → PROFILE_SELFTEST verdict=PASS samples=5248 top=runScriptInThisContext:97%,(garbage collector):3%
目标进程侧**只发一个 SIGUSR1**(Node 官方"开 inspector"信号)+ 只读 CDP 方法,不注入代码、不改状态。采样 500 µs、窗口 30 s、52,105 个样本。

2. 结果:79% 的 CPU 落在同一条调用链上

第 5.1 节 · user-b alpha 实例(PID 96955,app-[version],观测时进程态 Rl、稳态 97% CPU)
自耗占比函数调用链(自底向上)
31.5%(9676 ms)readFileUtf8readFileSync ← readFile:328 ← readLinuxStat:375 ← linuxProcessGroupHasLiveMembers:405 ← alive:806 ← runner-launch:833
16.2%(4970 ms)readFileUtf8同上(另一处调用点)
9.2%(2814 ms)readFileUtf8同上
8.7%(2684 ms)readdirreaddirSync ← readDir:329 ← linuxProcessGroupHasLiveMembers:405 ← alive:806 ← runner-launch:833 ← runMicrotasks
10.2%(合计)parseProcStat同上(runner-launch:343)
合计约 79%(30 秒窗口里 24.0 秒)。四个热点的祖先完全相同,且最外层是 runMicrotasks —— 说明它是一个**由 Promise 驱动的紧循环**,不是被请求触发的。

3. 代码侧:为什么它会 15 ms 一次扫全表

// dsh-subprocess-local/lib/runner-launch-*.js
function sleepTick() {                       // :714-716
  return setTimeout$1(15);                   //   ⇒ 每 15 ms 一轮
}
waitForExit: async () => {                   // :831-838
  observation ??= (async () => {
    while (alive()) await sleepTick();       // :834  ← 紧循环轮询
    stopped = true;
  })();
  await observation;
},

const alive = () => {                        // :806
  ...
  if (directSettled && platform === "linux"
      && linuxGroupHasLiveMembers(pid) === false) return false;   // ← 每轮都调
  return true;
};

function linuxProcessGroupHasLiveMembers(processGroupId) {   // :405
  entries = internals.readDir("/proc");                      // :407  enumerate 全表
  for (const entry of entries) {
    if (!/^\d+$/.test(entry)) continue;
    const stat = readLinuxStat(internals, Number(entry));     // :412  每个 PID 都 readFileSync
    if (stat?.pgrp !== processGroupId) continue;              //       只为筛出**一个**组的成员
    ...
  }
}
成本量级:本机 /proc 约 4600 个 PID 条目 ⇒ 每轮轮询 = 1 次 readdir + 约 4600 次 readFileSync("/proc/<pid>/stat") + 约 4600 次解析;每 15 ms 一轮 ⇒ 约 30 万次文件读/秒,而这一切只是为了回答「这个进程组还有没有活成员」。子进程越多,这种轮询器越多,且它们扫的是同一张全表。

4. 这同时消解了「兄弟请求」那处不同形(本页此前未解释清的一点)

第 5.2 节 · 两次观测其实是同一机制的两端
本页 §1(2026-09-08,低并发态)本次(2026-09-30,高并发态)
session/list零字节不返回零字节不返回(280 s)
兄弟请求100 ms 正常3.5–4.7 s(同慢)
进程 CPU请求自身归因 ≈ 0稳态 97%(同一条链占 79%)
解释同一个轮询器:并发低时它只吃掉 0.74–0.84 核(本页 §6 已把它记为「它山之石」的 /proc 全表扫描),事件循环仍有空档 ⇒ 兄弟请求 100 ms;并发高时多个轮询器同时扫同一张全表 ⇒ 事件循环被每 15 ms 掐一次 ⇒ 所有请求一起慢,session/list 自身还要做 O(N) 全量扫描,于是永远排不上。
因此 §1 的「兄弟 RPC 100 ms 正常」不是一条能区分根因的性质 —— 它是「当时有几个轮询器在跑」的函数。本页 §6 把 /proc 全表扫描列为**独立的「它山之石」行**,按本次证据应当**升格为同一根因**:它不是旁支,它就是饿死事件循环的那只手。

5. 与本页其余观测的闭合

  • 为什么会话多的空闲户没事、会话少的活跃户卡死(第 4.1 节:user-e 5136 会话没事,user-b 2214 会话卡死):轮询器只在**有受管子进程在飞**时存在。空闲户没有在飞子进程 ⇒ 没有轮询器 ⇒ 事件循环干净。
  • 为什么重启只能治好约 16 分钟(第 4.2 节):重启清掉了在飞子进程 ⇒ 轮询器消失;一旦 subagent 又开始跑,轮询器重新出现。
  • 为什么 session/list 是零字节:它要做 O(N)(N=2214)全量产物扫描,而事件循环每 15 ms 被一次全表扫描打断 ⇒ 它根本排不上队,连响应头都写不出去。

6. 修法(上游,二选一或并用)

  1. 把「组成员判定」从全表扫描改成定点判定:linuxProcessGroupHasLiveMembers 只需读该 pgrp 的成员,可用 kill(-pgrp, 0) 的返回值(已在 alive() 里先调了)配合 /proc/<pid>/stat 的**按需**读取,或缓存 /proc 目录项并按需失效;不必每次把全表 stat 都读一遍并解析。
  2. 给轮询加退避:sleepTick 固定 15 ms 过于激进;应做指数退避(如 15 ms → 1 s 上限),且同一 pgrp 的多个 waitForExit 应共享一个观察者而不是各起一个循环。
判据(可复跑,用于验收上游修法):一个从不为空的受管子进程组,在稳态下不应让宿主进程超过 5% 的 CPU;对照实验 = 同一实例跑/不跑 subagent 各取一次 --seconds 30 的 profile,热函数里不应再出现 linuxProcessGroupHasLiveMembers。

7. 产物与复跑

# profile 原始件(52,105 样本,含 JS 符号与行号)
[storage]/…/user-b-profile-20260930/user-b.cpuprofile

# 复跑(只读;SIGUSR1 只开 inspector,不改目标状态)
sudo -n -u user-k sudo -n node ops/dsh/profile-dsh-node.mjs --pid <PID> --seconds 30 --out /tmp/x.cpuprofile
node ops/dsh/profile-dsh-node.mjs --analyze /tmp/x.cpuprofile --top 6

2026-09-30 补全:三臂对照(全部自跑)+ inclusive 归因 —— 并发现第二个独立缺陷

上一节只 profile 了「饱和态」,且用的是 self-time 之和。本节把两处欠账补完:低并发对照臂由本席亲跑,归因改用 inclusive(包含)耗时。补完后暴露出一个此前没看到的热点。

1. 三臂对照:同一台 [host]、同一个 app-[version]、同一天,同一把尺子

第 6.1 节 · 三臂(每臂 30–45 s 窗口,采样 500 µs;命令见本页第 6.5 节)
臂实例会话数在飞子进程进程态 / CPUlinuxProcessGroupHasLiveMembers 链
A 饱和态user-b alpha221456 次写/3 minRl / 97%79%(24.0 s / 30 s)
B 空闲对照user-e alpha51360Sl / 8.8%一次都没出现(alive 仅 1 ms)
C 列表在飞user-e alpha51366 次 session/list—仍不出现
B 臂是本节最关键的一次测量:它与 A 臂同机同版本,会话数还是 A 臂的 2.3 倍,唯独没有在飞子进程 —— 于是整条 /proc 轮询链直接消失,(idle) 占 90.3%。这把「会话多 ⇒ 卡」彻底排除,把因果钉在「有受管子进程在飞」上(上一节只能引用 #7 的旧读数,现已自跑)。
A 臂的 inclusive 口径订正:上一节给的 79% 是四个热点节点的 self-time 之和;改用 inclusive 后,linuxProcessGroupHasLiveMembers 在两处调用点分别为 49.0%(15,044 ms)与 23.7%(7,285 ms),合计 72.7%;其上层 alive() 两处合计 73.2%。两个口径同量级、互相印证,页面此后一律以 inclusive 为准。

2. inclusive 归因:列表的耗时不在本页假设的那几个阶段

第 6.2 节 · C 臂(6 次 session/list,共约 24 s 工作量 / 45.3 s 窗口)逐函数 inclusive 对照 B 臂
函数B 臂(空闲)C 臂(列表在飞)增量
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(阻塞)≈0self 9.0%(4,074 ms)+9.0 pp
spawnSubprocess(runner-launch:1020)≈07.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 / listArtifacts0.2% / 0.2%0.3% / 0.1%≈持平
本页第 4 节设想的阶段(枚举 / header / 投影 / 排序 / 编码)合计不到 2%。真正吃时间的是起子进程:列表为每个会话走 spawn,而每次 spawn 都要先付一发 阻塞式 spawnSync 能力探测。(idle) 从 90.3% 掉到 44.0%,差额基本就是这里。

3. 第二个独立缺陷:每次起子进程都阻塞地探一次 systemd scope

// 调用链(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
  if (platform === "linux") {
    const available = this.linuxDeepProbePassed ? probeLinuxManager() : probeLinuxNative();
    if (available) this.linuxDeepProbePassed = true;       // ← 有缓存位……
    ...
}
两处都要问上游:① 能力探测放在**每次 spawn** 的热路径上,且用的是阻塞的 spawnSync(实测 self 9.0%,即这 4 秒里事件循环完全停摆)—— 探测结果应当是进程级一次性缓存(linuxDeepProbePassed 看起来想这么做,但实测每轮列表仍在付这笔钱,说明缓存没覆盖到这条路径,或是 probeLinuxManager() 自身也贵);② 列表路径上「每个会话一次 spawn」本身是否必要,值得先问再优化。

4. 两条缺陷的关系(为什么它们叠在一起最致命)

  • 缺陷②(阻塞 spawn 探测)让每次 session/list 在起子进程时反复停摆事件循环 —— 即使实例健康,列表也要 3.6–4.4 s(C 臂实测 6/6 次)。
  • 缺陷①(每 15 ms 全表扫 /proc)在有受管子进程在飞时把整机进程 CPU 拉到 97%,事件循环再被每 15 ms 掐一次。
  • 两者叠加时,session/list 既要反复阻塞 spawn、又要和全表扫描抢事件循环 ⇒ 零字节、连响应头都写不出去;同一时刻兄弟请求也从 100 ms 退化到 3.5–4.7 s(这正是上一节消解的那处不同形)。
本节的第二个缺陷已单列成一张 issue:issue #8 草案:每次 spawn 都阻塞地探一次 systemd scope(病灶模块、修法、影响面都与本页不同 —— 它影响所有起子进程的功能,不只列表)。

5. 复跑(全部只读)

# 取 profile(SIGUSR1 只开 inspector,不改目标状态)
sudo -n -u user-k sudo -n node ops/dsh/profile-dsh-node.mjs --pid <PID> --seconds 30 --out /tmp/x.cpuprofile

# 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'

# 发一次真列表(<token> 取该户 web.log 末条 "dsh web:")
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
三份 profile 原始件(各 5.2 万–7.2 万样本):[storage]/…/user-b-profile-20260930/{user-b,user-e-idle,user-e-list}.cpuprofile

责任归属:我们能改的 vs 上游修法

先分清责任,再谈修。这份分工是本页的"可执行"部分。

第 5 节 · 谁该修什么
类别内容负责方
本 issue 主体session/list 作为一元 RPC 永不返回(零字节、无响应头)上游 DSH
次要客户端自动选工作区一次失败即永久放弃 + 新建会话失败静默无操作(应允许重试并给可见反馈)上游 DSH
它山之石空闲期常驻 /proc 全表扫描(约 0.74–0.84 核常年、16.3 小时 43,739 CPU 秒)上游 DSH
我们侧缓解归档老旧大会话(ops/dsh/archive-sessions.sh,默认 dry-run、可 --restore)、给计算任务与 DSH 主进程做 CPU 隔离、把列表耗时纳入巡检我们
明确不是我们的 provision / gateway / Caddy / 鉴权链 —— 它们全程表现正常已排除
关于我们的缓解措施的诚实说明:归档治不了本 bug(我们把常驻集钉在 30 天量级的收益推演了,但header 扫描本身只要 0.17 秒,离 300 秒差三个数量级)。它治的是「列表随数据量单调恶化」这条趋势,属数据治理,不应写进 issue 的性能小节冒充修法。

可提交的 issue #7 正文(英文,可直接粘贴)

以下是给 github.com/deepseek-ai/deepseek-harness 的现成正文。默认英文(上游惯例)。

标题

`session/list` never responds: zero bytes and no response headers for up to 300 s on a plain unary RPC, while sibling RPCs on the same instance answer in ~100 ms

Environment

Host: [host], 256 logical cores, Linux. loadavg 1/5/15 = 21.80 / 20.11 / 24.26 at 2026-09-08 08:42:21 +0800
      (several unrelated Python pytest/parity jobs at 100-510% CPU; the box is ~73% idle).

Server: node [app-dir]/app/node_modules/.bin/dsh --profile infra --no-open           --host 127.0.0.1 --port 4002 --trusted-host <host>
        PID 29582, started 2026-09-07 16:21:55 +0800, uptime ~16.3 h, systemd unit dsh-user-d,
        NRestarts=0, 12 threads, cumulative %CPU 74.3 (~0.74 cores lifetime),
        utime 2,034,878 / stime 2,351,730 jiffies (sys > user).
        RSS = VmHWM = 4.43 GiB, Swap 0, majflt = 1;
        no --max-old-space-size / --max-heap flag is set.

Packages: [app-dir]/app/node_modules/@deepseek-ai/*   (node v24.16.0)
DSH_HOME: /home/user-d/.dsh

Corpus at first measurement: ~2,306-2,311 session directories, ~1.0 GiB on disk
        (session.jsonl.zstd), 2,240 projection-cache records, 3 project directories.
        storages/workspace.json registers 1 workspace ([repo], title infra)
        with 82 sessionIds and 7 archivedSessionIds.

Auth:   launch token rotates on each restart, read from [实例目录]/web-url.txt;
        GET /?token=<tok> -> HTTP 200 in 0.488 s, sets HttpOnly cookie dsh-auth-<...> (len 171).
        All RPCs below reuse that cookie. There is no Authorization header.

Flags:  coldBlankProbeMaxBytes left at the default 1024 (dsh-base does not override it);
        projection-cache write-behind: writeEveryEvents 200, writeIntervalMs 5000.

Steps to reproduce

# 1. Get a session cookie (token rotates on restart)
TOK=$(sudo -n -u user-k sudo -n grep -o "token=[^&]*" [实例目录]/web-url.txt | cut -d= -f2)
J=/tmp/jar_$$.txt
curl -sS -m 8 -c $J -o /dev/null -w "%{http_code}\n" "http://127.0.0.1:4002/?token=$TOK"    # -> 303

# 2. Send the byte-exact envelope the shipped client sends (131 B, captured from a real
#    browser navigation - no New Session click, i.e. a plain page load)
curl -sS -m 60 -b $J -H 'Content-Type: application/json' \
  --data '{"type":"client-request","rpcId":"32f2434f-e6bc-49f2-be3b-8c649ba43d74",
           "method":"session/list","payload":{"args":{"_request":{}}}}' \
  -w '\ncode=%{http_code} total=%{time_total} first=%{time_starttransfer} size=%{size_download}\n' \
  http://127.0.0.1:4002/api/session/list
# -> code=000 total=60.01 first=0.000000 size=0     (curl exit 28)

# 3. Controls, identical cookie and transport (all 200, transfer-encoding chunked,
#    content-encoding gzip, body fully read 115-197 ms after the first byte):
curl ... --data '{"type":"client-request","rpcId":"c1","method":"session/modelCatalog",
                  "payload":{"args":{}}}' ... /api/session/modelCatalog
#    -> 200   0.033-0.238 s   1728 B   (4/4, plus 2/2 in a low-load window)
#    settings/describe            -> 200  0.049 / 0.108  26961 B
#    session/canOpenWorkspacePath -> 200  0.040 / 0.045    283 B
#    agentPresets/list (also walks the filesystem) -> 200  4.434-5.951 s  1288 B

# 4. THE KEY MEASUREMENT: issue step 2 again, and while it is in flight keep hitting a
#    sibling endpoint. The event loop is demonstrably alive.
/home/user-l/seat5/seat5_critpath.py
# list                      -> TimeoutError, total 30.0302 s, first_byte None, bytes 0
# modelCatalog while stalled -> 8/8 HTTP 200, first-byte 0.090 / 0.098 / 0.111 / 0.119 /
#                              0.143 / 0.147 / 0.166 / 1.122 s (median 0.131), 1733-1734 B
# idle baseline (x3)         -> 0.097 / 0.117 / 0.099 s
# MainThread CPU, whole 30 s window -> 25.17 s = 0.838 cores
#   of which the 12.37 s sub-window with the request in flight -> 9.99 s = 0.807 cores
# => request-attributable CPU ~ 0

# 5. Load ablation already performed: identical zero-byte behaviour at loadavg(1m)
#    10.89, 13.94, 14.89, 15.11, 16.07, 16.40, 18.50, 20.39, 34.50  (9 readings),
#    plus a 300 s-budget run (300.312 s, 0 bytes). No config change, no process restart,
#    ever on the same PID 29582.

# 6. Corpus census used above (read-only):
#    files_total 2308, session.jsonl.zstd, dir 1006.5 MiB, uncompressed 2.77 GB,
#    projcache 2240 records at 08:44, workspace.json 1 workspace / 82 sessionIds / 7 archived.

Expected behaviour

session/list is declared as a plain unary RPC: the generated descriptor (dsh-api-session-controller/lib/typert.host.js:873-905, source packages/api/session-controller/src/index.ts:214:9) has invocation: { kind: "direct" } and no mode: "stream"; its single wire parameter is _request (z.object({ cursor: z.string().optional() })); dsh-api-gateway/lib/index.js:538-548 therefore does await Reflect.apply(...) and serialises one JSON response.

Given (a) the documented design intent that listing is a light path — dsh-session-persistence-jsonl/README.md:93 "Listing reads and validates only the header frame." and dsh-session-query/README.md:145 "listSessions stays lightweight"; (b) that the projection columns can be served from memory with zero I/O (dsh-session-projection-cache/README.md:61); and (c) that an independent out-of-process replay of listArtifacts over this exact corpus (3 project dirs / 2,306 session dirs, 4,612 open + 2,306 8 KiB reads + 2,306 zstd header frames) measures 0.92-1.00 s wall / 1.16-1.23 s CPU, and an independent header-only scan measures 0.171 s serial / 0.092 s at 8-way over 2,308 files, the endpoint should return a single { items: SessionSummary[] } within a few seconds even with ~2,300 accumulated sessions, and should emit its response headers as soon as the handler resolves. If something is genuinely slow or broken, the expected contract is a bounded failure (SESSION_QUERY_PERSISTENCE_FAILED) or a partial result — not silence.

Actual behaviour

The endpoint stalls before the Response object exists, so not even response headers are emitted; the socket carries zero bytes for the whole budget. Measured 10/10 times between 08:26 and 08:52 (+0800): seven runs at -m 60 (60.011-60.018 s, first byte 0.000000, 0 B, curl exit 28), one at -m 300 (300.312 s, 0 B), one at -m 30 (30.030 s, 0 B) and two follow-up runs at -m 90 (90.0011 s / 90.0016 s, 0 B). Load-independence is total (see step 5). In the browser, the real client-issued session/list receives no response headers inside a 20 s window and the session list never renders; only the ordinary composer placeholder "Choose a workspace to start" (i18n placeholder.workspace) is shown, which is also why this failure is so easy to misread as "empty workspace".

The stall is not accompanied by request-attributable CPU (numbers in step 4): MainThread burns 0.807 cores with the request in flight versus 0.838 cores over the same window without it, statistically identical, and 1.10-1.48 cores across every sampled window (407.7 CPU-s over 360 s = 1.13 cores). Thread-level sampling shows MainThread at ~99% of one core in state R while all four libuv workers (0.07-0.09 s / 20 s) and the V8 workers (0.03-0.04 s / 20 s) idle, so it is not blocked on async I/O — yet it never settles the request.

Two side observations that may or may not be related (we are not claiming causation): while no request of ours is in flight (08:43:19/22/25/28, 3 s apart) the process holds 16 / 16 / 17 / 23 concurrently open session.jsonl.zstd descriptors under [repo-slug]/<id>/, the set changing on every sample, with /proc/<pid>/fdinfo pos mostly 0 or 8192 (the 8 KiB pre-read of readFirstZstdLine); and the projection cache is being rewritten continuously (2,240 records; 10 file mtimes changed per 10 s, 83 within 60 s, newest at 08:43:44). Every storage-domain write is serialised on one chain (dsh-storage-domain/lib/index.js:97 "every write queues on the chain, awaits backend durability", enqueue at :221), and session/list's projection column reads the same domain.

Impact

  • The session list is the app's entry surface. On this instance the browser never renders a workspace session list, so the Web UI is effectively unusable for the human operator even though the process is alive, authenticated, and answering every other RPC.
  • The failure is silent and total: no error body, no 4xx/5xx, no timeout, no partial result. The client has nothing to retry against and cannot distinguish "this workspace is empty" from "the server hung".
  • The cost is invisible to the usual dashboards: ~0 request CPU, read_bytes ~ 0 (page cache), zero bytes written to the socket. CPU/IO-based alerting reports the instance as healthy.
  • It does not self-heal: identical across browser restarts, low-load windows and a 300 s budget. It is a whole-endpoint outage, not a slow path.
  • Blast radius grows monotonically with corpus size: nothing in the shipped seam deletes session files (dsh-session-persistence-jsonl/README.md known limits: "Nothing deletes session files"), the projection cache has no eviction ("No eviction or retention surface"), and the listing path has no memory cache and no TTL — every first paint and every workspace switch re-walks the entire corpus. This instance reached ~1.0 GiB / ~2,300 sessions in under 7 days (~526 sessions/day).
  • Secondary, independently observable operator pain in the same process: ~0.74-0.84 cores burned continuously for 16.3 h (43,739 CPU-s); 2.6 billion read syscalls (44,220/s mean) and 4.34 TB rchar over the process lifetime; RSS pinned at its lifetime high-water mark (4.43 GiB) with no heap cap configured and 2,240 projection-cache records that nothing evicts.

What we already ruled out (all tested on the live instance)

  1. Authentication / token rotation. GET /?token=... returns 200 in 0.488 s and sets the HttpOnly cookie; every subsequent RPC with it succeeds (step 3). A stale or missing cookie yields an immediate 401, never zero bytes for 300 s.
  2. A malformed envelope. The 131-byte body the shipped client actually sends (captured from a real navigation) is byte-identical to the hand-written one and was replayed verbatim. Argument-shape violations are rejected synchronously by assertExactArguments (dsh-api-gateway/lib/index.js:1045, call site :746) with gateway/arguments-invalid - an error, not a hang. _request is the correct and only required key (source: json, strict codec, no acceptsUndefined; the payload must contain exactly one plain-object args field, :934).
  3. Streaming / subscription semantics. The descriptor has invocation.kind: "direct" and no mode: "stream"; _request is a "reserved empty list request" (dsh-api-session-controller/lib/index.js:2690) whose only legal field, cursor, is ignored. The real streams in this gateway are $events (:13, openRemoteEvents :579-593, which requires an empty args object) plus SessionEventStream / SessionControlStream - none of them is session/list.
  4. Host CPU contention. The host has 256 cores and is ~73% idle; the unrelated pytest/parity jobs (100-510% each) were never touched. The identical zero-byte result reproduces at nine loadavg(1m) readings from 10.89 to 34.50.
  5. Whole-event-loop starvation / a synchronous main-thread monopoly. During a confirmed 30 s stall, 8/8 concurrent session/modelCatalog requests returned 200 with first byte at 0.090-1.122 s (median 0.131), versus an idle baseline of 0.097-0.117 s. The event loop schedules within ~100 ms granularity while session/list is stuck.
  6. Data corruption / missing files / fail-closed abort. A read-only census of all ~2,311 session directories found 0 bad zstd magics, 0 empty logs and 0 missing logs; an independent parse of all 2,306 header frames succeeded for every one, with no duplicate ids and no legacy flat layout. This matters because the seam is fail-closed (dsh-session-query/lib/index.js:247-253 wraps enumeration errors into SESSION_QUERY_PERSISTENCE_FAILED): a corrupt artifact produces a fast error, the opposite of what we observe.
  7. A missing or unmounted data directory. All logs are present and readable; the out-of-process listArtifacts replay over the same tree completed ALL_OK and found the >10 MB (6), 1-10 MB (99) and 100 KB-1 MB (1,841) files exactly where expected, all <7 days old.
  8. Disk I/O latency or swapping. read_bytes ~ 0 during the stall (page cache), the process reports Swap 0.0 and majflt = 1 over its whole 16.3 h life, and an equivalent enumeration of the same corpus finishes in 0.92-1.00 s wall.
  9. Algorithmic complexity. Listing reads only the first (header) frame per session and never event bodies, so the cost is O(sessions), not O(sessions x events). readSession / readSurface / filterEvents / observeSession are not on this path.
  10. The probeSmallCold full-replay branch. It is gated by stat(path).size > coldBlankProbeMaxBytes (default 1024 B here) and is reached only for cold sessions that are uncached or blank === true; with 2,240 cached records against ~2,306 sessions that is <= ~112 stats, each returning immediately for logs larger than 1 KiB.
  11. Per-endpoint serialisation by an earlier stuck request. dsh-client-connection/lib/index.js has no endpoint-level mutex or queue, only socket backpressure (res.off/once("drain"), :73/:77), and the concurrent-control experiment proves the endpoint is not saturated.

Still open / what we could not prove

We would rather hand you a precise hole than a wrong root cause. These remain candidates only:

  1. Contention between the projection-cache write-behind and the listing's projection column over the single storage-domain chain (lib/index.js:97 / enqueue :221). We have existence evidence for the chain and for continuous writes, but no direct evidence that the listing is queued behind it.
  2. V8 heap pressure near the default cap (RSS = VmHWM = 4.43 GiB, no --max-old-space-size). The idle V8Worker time (0.03-0.04 s per 20 s) argues against it, so the two pieces of evidence conflict.
  3. The always-on header-level scan running in idle time (16-23 rolling session-log descriptors) contending for the same corpus as the listing.
  4. A client-side state machine defect we reproduced separately: dsh-client-ui-workspace watchNavigation performs auto workspace selection exactly once and, if it finds no target, latches initial = "done" and never retries; startSession() with no target is a silent sessions.clear(); return;. We list this as secondary because fixing the server-side listing would reduce it to a slightly late first paint rather than an empty UI.
To close the remaining gap, two things are needed and neither is available to us as deployers: (1) a CPU profile with JS symbols (node --cpu-prof or --perf-basic-prof); (2) stage timestamps around sessionController.list (enumerate / header / projections / sort / encode). The current code path has no instrumentation at all, which is precisely why a five-way parallel investigation could not attribute this further.

Suggested fixes (upstream)

  1. Instrument the endpoint, then look. Add stage timings or a debug log with per-phase durations inside sessionController.list / ApiSessionList.list / listArtifacts so this class of stall stops being invisible. Without this, no amount of operator-side measurement can attribute it.
  2. Stream or at least flush early. Because the handler awaits the entire corpus walk before a Response exists, clients see zero bytes and cannot distinguish a hang from a slow answer. Emitting headers (or a first partial page) before the walk would turn an outage into visible latency.
  3. Put a memory cache / short TTL on the listing. The corpus walk is repeated on every first paint and every workspace switch, has no cache and no TTL, and nothing ever deletes session files. A header index (persisted or in-memory with mtime invalidation) would bound the cost instead of letting it grow with the corpus.
  4. Bound the work per call. listArtifacts is a fully serial loop of await; adding bounded concurrency (the pattern SessionCorpus.projectMany already uses per dsh-session-query/README.md:104) and honoring the existing (currently ignored) _request.cursor for pagination would give the endpoint a ceiling.
  5. Consider workspace scoping. session/list returns the entire corpus with no workspace argument, while the UI only needs the sessions of one workspace (82 of ~2,300 in our case) — the client joins against workspace.json by itself.
  6. Warn about the coldBlankProbeMaxBytes cliff. Raising it makes probeSmallCold do a full log replay + fold for cold sessions; nothing in the config documents that this turns listing into O(events).
  7. Client side. Let watchNavigation retry auto-selection when the workspace/session stores become ready, and give startSession()'s no-target path a visible outcome instead of a silent no-op.

复现脚本与命令

脚本都落盘了,可以直接复跑;它们对生产只读。这些脚本现放在 /home/user-l/(尚在收编,见页脚"待办")。

第 7 节 · 复现与取证脚本
脚本干什么判定
/home/user-l/seat5/seat5_critpath.py核心实验:登录 → 发起 session/list → 在挂起窗口内并发打 8 次 modelCatalog,并 diff 主线程 CPUlist 超时 + 8/8 兄弟请求 200
/home/user-l/seat5/seat5_fd_and_stage.py无请求在飞时快照 /proc/<pid>/fd,统计常年开着的会话日志 fd16–23 个滚动 fd
/home/user-l/seat5/seat5_fdinfo.py读 /proc/<pid>/fdinfo 的 pos,识别 8 KiB 预读签名pos 多为 0 或 8192
/home/user-l/seat5/seat5_projchurn.py观测 projcache 写入速率10 秒 10 个文件变更
/home/user-l/seat1_listbench.js进程外复刻 listArtifacts 全流程(枚举+解 header),给 host 侧下界wall 0.92–1.00 s
/home/user-l/bench_header_scan_seat3.py只解第一个 header 帧的基准(串行/4 路/8 路)+ 体积与年龄分桶串行 0.171 s,8 路 0.092 s
/home/user-l/dsh_session_list_diag.py登记表 ↔ 磁盘 双向差集 + 全部 header 帧校验 + cwd 有效性header_bad=0
/home/user-l/dsh_ui_probe.py真浏览器首屏取证:DOM 文本 + 全部网络请求 + console/pageerrorUI 侧现状
/home/user-l/ej2_one_long.py-m 300 长预算单次探针300.312 s / 0 字节

一次性复跑(最短路径)

# 1) 登录 + 打一次 session/list(60 秒预算即可见到现象)
TOK=$(sudo -n -u user-k sudo -n grep -o 'token=[^&]*' [实例目录]/web-url.txt | cut -d= -f2)
J=/tmp/jar.txt; curl -sS -m 8 -c $J -o /dev/null "http://127.0.0.1:4002/?token=$TOK"
curl -sS -m 60 -b $J -H 'Content-Type: application/json' \
  --data '{"type":"client-request","rpcId":"v1","method":"session/list","payload":{"args":{"_request":{}}}}' \
  -w '\ncode=%{http_code} total=%{time_total} first=%{time_starttransfer} size=%{size_download}\n' \
  http://127.0.0.1:4002/api/session/list

# 2) 核心反证实验(挂起期间事件循环仍活)
sudo -n -u user-k sudo -n /usr/bin/python3 /home/user-l/seat5/seat5_critpath.py

# 3) 数据侧体检(确认不是数据坏了)
sudo -n -u user-k sudo -n python3 /home/user-l/dsh_session_list_diag.py --home /home/user-d/.dsh --json /tmp/diag.json
原始数据落盘位置:/home/user-l/seat5/critpath.json、fd_scan.json、fdinfo.json、projchurn.json、/home/user-l/ej2/*/timing.json、/home/user-l/ej_final/issue_context_20260908.json(本文后续补测的 2 × -m 90 也记在这里)。

顺带发现的两个副作用

与上面的挂起同源同进程,但我们不主张因果,因此单列。两条都独立可观测、独立可提 issue。

A. 空闲期常驻 /proc 全表扫描,常年吃 0.74–0.84 核

带 JS 符号的火焰图我们拿不到(不是 --perf-basic-prof 起的 node),但 51% 的主线程内核样本落在两个系统调用族上:getdents64 → proc_root_readdir → proc_pid_readdir 约 12.8%(读 /proc 目录本身)、read → proc_tgid_stat → do_task_stat 约 6.0%(读每个 PID 的 /proc/<pid>/stat)。生命周期的总账:26 亿次 read(均值 44,220 次/秒)、rchar 4.34 TB、而 read_bytes 只有 197.6 GB(说明绝大多数命中缓存但依然要走系统调用)。

形状对应的代码在 dsh-subprocess-local/lib/index.js:PosixProcessInspector.snapshot()(347 / 523 / 526 / 538 行:readDir("/proc") → 逐 pid readDir("/proc/<pid>/task") → readFile("/proc/<pid>/stat"))被 waitForMembers()(1135–1141 行,await delay(min(25,…)))以≤25 毫秒的节奏全量快照,直到成员退出。本机当时有 2,847 个进程,一次快照约 6k 次系统调用 × 约 40 次/秒 ≈ 24 万次/秒,与实测的 5 万次/秒、约 1 核量级吻合。

诚实标注:这是"系统调用形态 + 轮询节奏 + 进程规模"三重吻合推出的对应关系,不是符号级因果。它的意义是:它独立解释了"背景那 0.8 核是谁在吃",从而让第 2 节的"请求自身 CPU ≈ 0"这个结论更干净。

B. 投影缓存只增不减

2240 条记录、无逐出、无上限(上游原文 "No eviction or retention surface"),与线程采样里 V8Worker 近乎不干活(20 秒窗口 0.03–0.04 秒)形成矛盾,所以"RSS 4.43 GiB 是否已抑制 GC"这条我们也留在第 4 节未定论。