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 毫秒 —— 说明服务端的事件循环是活的,所以"整台机器被别的任务压死"这个解释解释不通。
三条实测事实就足以定性,不需要先知道「根因叫什么名字」。
用大白话说:DSH 网页左边那栏"历史会话列表",是靠一个叫 session/list 的接口填的。接口不返回,列表就永远是空的 —— 界面看起来"登录成功了但里面什么都没有",因为前端连"我有几个会话"都不知道。这和你的 token、跟会话文件有没有坏,都没关系。
| 事实 | 实测数字 | 它排除了什么 |
|---|---|---|
| 接口完全不回 | 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 毫秒) | 排除「服务端被同步任务彻底钉死」:如果是,兄弟请求也该一起卡 |
read_bytes ≈ 0(全打 page cache),写入 socket 0 字节。任何按 CPU / IO 告警的看板都会报「健康」。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)
预算 -m | 耗时 | 首字节 | 字节数 | HTTP 码 |
|---|---|---|---|---|
| 60 s | 60.011–60.018 s | 0.000000 s | 0 | 000(超时) |
| 60 s | 60.011–60.018 s | 0.000000 s | 0 | 000(超时) |
| 300 s | 300.312 s | 0.000000 s | 0 | 000(超时) |
| 30 s | 30.030 s | 0.000000 s | 0 | 000(超时) |
| 90 s | 90.0011 s | 0.000000 s | 0 | 000(超时) |
| 90 s | 90.0016 s | 0.000000 s | 0 | 000(超时) |
合计 10 次全部零字节(其中 7 次 -m 60、1 次 -m 300、1 次 -m 30、2 次 -m 90 为本文补测)。
这是本页最重要的一条。如果一个服务端被同步任务彻底钉死(比如在跑同步系统调用),那么所有请求都该一起卡;但我们在 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
上一版我们只知道接口不回。这一版把「不回」和「你看到的左侧空白」之间的链条补上了:我们在真实页面里追踪浏览器自己发出的请求(不是我们手打的 curl),等 20 秒看它落什么状态。
| 状态 | 端点 | 含义 |
|---|---|---|
| 200 完成 | session/modelCatalog、session/create、settings/describe、credentials/describe、agentPresets/list、commands/list、skills/list、dynamicCordisRunner/inventory | 非枚举类端点全部正常 |
| 20 秒仍未回 | session/list 和 subagents/list | 两个枚举类端点同时挂:共因在「枚举/扫描」这条路径,而不是某一个端点的孤立缺陷 |
| 未失败 | (requestfailed 为空) | 不是连接被拒/中断,是服务端一直不结算 |
workspace/follow 流)在 324 毫秒内送到的,完全完好;但每一行的摘要要由 session/list 填 —— 那个 RPC 永久 pending,于是有 82 个 id 也渲染不出 82 行,侧栏只剩一个光秃秃的工作区名。workspace/follow 能拿到 82 个 id」推断「侧栏不依赖 session/list」。这条是错的:id 关系与摘要行是两段数据,前者完好不能推出后者能拿到。是上面这份请求追踪把它证伪的。这次真实点击还撞出第二个问题:空白首屏上点一次工作区不生效,得点第二次才打开会话。逐步取证:
| 步骤 | 界面 | 浏览器里持久化的当前会话 |
|---|---|---|
| 初始 | 显示占位「选择一个工作区开始」 | 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 落得晚,第一次点击就没人把会话打开,界面停在"已选工作区但无会话"。
sessions.create() 真实新建了一个空会话 session-3617a309-d6e3-4ebf-b6bd-9b38970d41c1(界面上显示为一条 "New Session"),我们没有删除它(该 seam 本身也没有删除 API),请按需在界面上处理。这是为了验证交互而做的真实点击所产生,不是故障。| 维度 | 上游文档/代码要求 | 实测行为 |
|---|---|---|
| 调用形态 | 生成式描述符 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 项) |
写 issue 最值钱的部分不是"我猜是这样",而是"这些我都试过了,不是它们"。以下每项都有命令或源码行号支撑。
GET /?token=… 0.488 秒返回并下发 HttpOnly cookie;随后同一 cookie 的所有 RPC 都成功。过期 cookie 是立刻 401,绝不会零字节拖 300 秒。assertExactArguments 是同步抛错(gateway/arguments-invalid),不是掛起;_request 也确实是唯一合法且必需的键。invocation.kind: "direct" 且无 mode: "stream";_request 在实现里叫 "reserved empty list request",其唯一合法字段 cursor 被 host 完全忽略。真正的流是 $events 与 SessionEventStream,都不是这个端点。ALL_OK。read_bytes ≈ 0(全打 page cache);进程 Swap 0.0、16.3 小时里 majflt = 1。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 互斥或队列;并发对照实验也从反面证明了这点。这一节是我们对自己的约束:以下四条都只是「形状相符」,我们拿不到直接证据,所以不写进 issue 正文的定论里。
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 个)两个证据,但没有"列表被它堵住"的直接证据。缺实验--max-old-space-size,支持这个假设;但线程采样里 V8Worker 只占 0.03–0.04 秒(20 秒窗口)是反证。两个证据互相矛盾,未裁决。缺实验dsh-client-ui-workspace 的 watchNavigation 只在首次做一次自动选工作区,一旦落空就 initial = "done" 不再重试;startSession() 拿不到目标时更是 sessions.clear(); return;(静默无操作)。但只要本 issue 的列表接口先修好,这个客户端问题就退化成"首屏晚一点",不足以让界面变空,所以我们把它列为次要项。次要node --cpu-prof / --perf-basic-prof 带 JS 符号的火焰图;②在 sessionController.list 的各个阶段(枚举 / header / 投影 / 排序 / 编码)打时间戳。当前这条代码路径没有任何分段计时,这正是我们 5 个并行取证席都无法把它归因下去的结构性原因。本节补上第 4 节列为「缺实验」的第 1 条候选所缺的那次对照。全部为当日现读,命令在节末。
| 户 | 会话数 | 近 3 分钟被写的会话文件 | session/list 实测 |
|---|---|---|---|
| user-e | 5136 | 0 | 3807 ms |
| user-c | 3525 | 1 | 2839 ms |
| user-a | 1841 | 0 | 1525 ms |
| user-m | 1452 | 9 | 1418 ms |
| user-b | 2214 | 56 | > 240 s(超时) |
| 时刻 | RSS | CPU | session/list |
|---|---|---|---|
| 重启后即刻 | 0.34 GB | 23 % | 2119 ms |
| +2 分钟 | 0.60 GB | 43 % | — |
| +16 分钟 | 1.11 GB | 91 % | > 240 s |
| +24 分钟 | 1.18 GB | 95 % | > 280 s(0 字节) |
curl -m 280 … /api/session/list → code=000 t=280.000373 size=0,零字节、连响应头都没有,与本页第 1 节的签名逐字一致。重启只重置了累积状态,没有消除触发条件,所以约 16 分钟后原样复发。| 候选 | 本组证据 |
|---|---|
① 投影缓存 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 次写),而它照样卡;③不足以解释本组。 |
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) 全量扫描,没有任何索引或缓存。
# 逐户会话数 + 延迟(含超时判词)
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
本节补上第 4 节自认「要定案只差的两样东西」中的第 ① 样(带 JS 符号的火焰图),并顺带把第 ② 样(分段归因)从函数名读出来;同时回答一个此前没解释清的差异。
SIGUSR1 进 inspector 取 profilenode 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 个样本。| 自耗占比 | 函数 | 调用链(自底向上) |
|---|---|---|
| 31.5%(9676 ms) | readFileUtf8 | readFileSync ← 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) | readdir | readdirSync ← readDir:329 ← linuxProcessGroupHasLiveMembers:405 ← alive:806 ← runner-launch:833 ← runMicrotasks |
| 10.2%(合计) | parseProcStat | 同上(runner-launch:343) |
runMicrotasks —— 说明它是一个**由 Promise 驱动的紧循环**,不是被请求触发的。// 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 万次文件读/秒,而这一切只是为了回答「这个进程组还有没有活成员」。子进程越多,这种轮询器越多,且它们扫的是同一张全表。| 本页 §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) 全量扫描,于是永远排不上。 | |
/proc 全表扫描列为**独立的「它山之石」行**,按本次证据应当**升格为同一根因**:它不是旁支,它就是饿死事件循环的那只手。session/list 是零字节:它要做 O(N)(N=2214)全量产物扫描,而事件循环每 15 ms 被一次全表扫描打断 ⇒ 它根本排不上队,连响应头都写不出去。linuxProcessGroupHasLiveMembers 只需读该 pgrp 的成员,可用 kill(-pgrp, 0) 的返回值(已在 alive() 里先调了)配合 /proc/<pid>/stat 的**按需**读取,或缓存 /proc 目录项并按需失效;不必每次把全表 stat 都读一遍并解析。sleepTick 固定 15 ms 过于激进;应做指数退避(如 15 ms → 1 s 上限),且同一 pgrp 的多个 waitForExit 应共享一个观察者而不是各起一个循环。--seconds 30 的 profile,热函数里不应再出现 linuxProcessGroupHasLiveMembers。# 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
上一节只 profile 了「饱和态」,且用的是 self-time 之和。本节把两处欠账补完:低并发对照臂由本席亲跑,归因改用 inclusive(包含)耗时。补完后暴露出一个此前没看到的热点。
app-[version]、同一天,同一把尺子| 臂 | 实例 | 会话数 | 在飞子进程 | 进程态 / CPU | linuxProcessGroupHasLiveMembers 链 |
|---|---|---|---|---|---|
| A 饱和态 | user-b alpha | 2214 | 56 次写/3 min | Rl / 97% | 79%(24.0 s / 30 s) |
| B 空闲对照 | user-e alpha | 5136 | 0 | Sl / 8.8% | 一次都没出现(alive 仅 1 ms) |
| C 列表在飞 | user-e alpha | 5136 | 6 次 session/list | — | 仍不出现 |
/proc 轮询链直接消失,(idle) 占 90.3%。这把「会话多 ⇒ 卡」彻底排除,把因果钉在「有受管子进程在飞」上(上一节只能引用 #7 的旧读数,现已自跑)。linuxProcessGroupHasLiveMembers 在两处调用点分别为 49.0%(15,044 ms)与 23.7%(7,285 ms),合计 72.7%;其上层 alive() 两处合计 73.2%。两个口径同量级、互相印证,页面此后一律以 inclusive 为准。| 函数 | 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(阻塞) | ≈0 | self 9.0%(4,074 ms) | +9.0 pp |
spawnSubprocess(runner-launch: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 | 0.2% / 0.2% | 0.3% / 0.1% | ≈持平 |
spawn,而每次 spawn 都要先付一发 阻塞式 spawnSync 能力探测。(idle) 从 90.3% 掉到 44.0%,差额基本就是这里。// 调用链(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; // ← 有缓存位……
...
}
spawnSync(实测 self 9.0%,即这 4 秒里事件循环完全停摆)—— 探测结果应当是进程级一次性缓存(linuxDeepProbePassed 看起来想这么做,但实测每轮列表仍在付这笔钱,说明缓存没覆盖到这条路径,或是 probeLinuxManager() 自身也贵);② 列表路径上「每个会话一次 spawn」本身是否必要,值得先问再优化。session/list 在起子进程时反复停摆事件循环 —— 即使实例健康,列表也要 3.6–4.4 s(C 臂实测 6/6 次)。/proc)在有受管子进程在飞时把整机进程 CPU 拉到 97%,事件循环再被每 15 ms 掐一次。session/list 既要反复阻塞 spawn、又要和全表扫描抢事件循环 ⇒ 零字节、连响应头都写不出去;同一时刻兄弟请求也从 100 ms 退化到 3.5–4.7 s(这正是上一节消解的那处不同形)。# 取 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
[storage]/…/user-b-profile-20260930/{user-b,user-e-idle,user-e-list}.cpuprofile先分清责任,再谈修。这份分工是本页的"可执行"部分。
| 类别 | 内容 | 负责方 |
|---|---|---|
| 本 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 / 鉴权链 —— 它们全程表现正常 | 已排除 |
以下是给 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
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.
# 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.
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.
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.
read_bytes ~ 0 (page cache), zero bytes written to the socket. CPU/IO-based alerting reports the instance as healthy.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).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.We would rather hand you a precise hole than a wrong root cause. These remain candidates only:
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.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.脚本都落盘了,可以直接复跑;它们对生产只读。这些脚本现放在 /home/user-l/(尚在收编,见页脚"待办")。
| 脚本 | 干什么 | 判定 |
|---|---|---|
/home/user-l/seat5/seat5_critpath.py | 核心实验:登录 → 发起 session/list → 在挂起窗口内并发打 8 次 modelCatalog,并 diff 主线程 CPU | list 超时 + 8/8 兄弟请求 200 |
/home/user-l/seat5/seat5_fd_and_stage.py | 无请求在飞时快照 /proc/<pid>/fd,统计常年开着的会话日志 fd | 16–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/pageerror | UI 侧现状 |
/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。
/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 核量级吻合。
2240 条记录、无逐出、无上限(上游原文 "No eviction or retention surface"),与线程采样里 V8Worker 近乎不干活(20 秒窗口 0.03–0.04 秒)形成矛盾,所以"RSS 4.43 GiB 是否已抑制 GC"这条我们也留在第 4 节未定论。