DeepSeek Harness web 面板永久 loading:keepAliveTimeout 超时排查

故障排查发布于 2026-10-20作者: DeepSeek Plugin 插件市场
DeepSeek Harnessdsh webkeepAliveTimeoutheadersTimeout连接挂起
DeepSeek Harness 的 dsh web 页面空闲超过 Node 默认 5 秒后,插件面板请求写进正在关闭的 keep-alive 连接,promise 永不 settle。本文给服务端超时、客户端重试、反代对齐三条路。

页面空闲几秒后插件面板永久转圈,不是前端状态机坏了,而是请求写进了一条正在被服务端关闭的 keep-alive 连接。 dsh-host-webserver 创建 HTTP 服务时没有设置 keepAliveTimeout 与 headersTimeout,Node 默认的 5 秒空闲超时生效;浏览器恰好在边界上复用连接,就会撞上 close/send 竞态,请求既不返回也不报错(Discussion #10106)。

DeepSeek Harness web 面板挂起复现:连接空闲 8 秒后再发请求

用裸 socket 就能把「服务端正常、浏览器挂起」拆开。 讨论里的复现脚本在 dsh web 的端口上跑两次请求:

python
import http.client, time
c = http.client.HTTPConnection('127.0.0.1', 3080, timeout=10)
c.request('GET', '/'); print(c.getresponse().status)
time.sleep(8)
c.request('GET', '/'); print(c.getresponse().status)
  1. 跑第一段请求,确认新连接立即返回 200。预期结果:状态码正常,连接进入 keep-alive 池。
  2. 睡眠 8 秒后复用同一连接再请求。预期结果:抛 ConnectionResetError 或一直等到自己的 10 秒超时。
  3. 用 lsof -nP -iTCP:3080 看连接状态。预期结果:出现 CLOSE_WAIT,即服务端已关闭而客户端尚未察觉。
  4. 换一条全新连接请求同一个路径。预期结果:立刻成功,证明服务端本身没有卡死。

第 4 步是关键判据:新连接永远正常,只有复用空闲连接才会挂,这就是连接层而不是业务层的问题。

DeepSeek Harness 机制修正:浏览器并非毫不知情

真正导致永久 loading 的不是「客户端不知道连接要关」,而是关闭与发送撞在同一时刻。 讨论回复里做了两点澄清:

  1. Node 会在每个 keep-alive 响应里带 Keep-Alive: timeout=5,Chrome、Firefox、Safari 都据此提前淘汰空闲 socket,所以浏览器不是盲的。
  2. 能产生「promise 永不 settle」的是 close/send race:客户端写入的瞬间服务端定时器触发,字节被一条正在拆除的连接接收,没有响应生成,客户端只能等到自己的超时。
  3. 因此修法仍然是设置超时,但必须满足 headersTimeout > keepAliveTimeout。Node 不强制这个关系,语义强制。
  4. 压缩不是宿主默认行为,DEFAULT_COMPRESSION 是 none,gzip 由 bundle 层打开,所以改超时不会和压缩互相影响。

这也解释了为什么「刷新一下就好」:刷新会建立新连接,绕开了那条正在死亡的 socket。

这两条澄清不改变修法,但改变了验证方式:不要用「客户端有没有弹超时」判断是否修好,要看同一条空闲连接复用后能不能拿到完整响应。客户端侧的错误提示只能说明它等到了自己的超时,和连接是否健康无关。

DSH插件路径一:在服务端同时设置两个超时

这是唯一根治的方向,两个值必须一起设,且 header 超时更大。 在 createServer 之后、listen 之前加:

js
server.keepAliveTimeout = 60_000;
server.headersTimeout = 65_000; // 必须大于 keepAliveTimeout
  1. 确认当前宿主版本里全仓搜不到这两个配置项。预期结果:keepAliveTimeout 与 headersTimeout 都是 0 命中,说明用的确实是 Node 默认值。
  2. 在 webserver 创建服务实例的位置补上两行赋值。预期结果:服务正常启动,不报参数错误。
  3. 重启 dsh web,重复上一节的空闲 8 秒复用测试。预期结果:第二次请求正常返回 200,不再重置或挂起。
  4. 把页面放 30 秒不操作,再打开一个插件面板。预期结果:面板正常加载,不需要刷新。

如果你的宿主版本已经把这两个值暴露成配置项,优先改配置而不是改源码;判断依据是配置 schema 里有没有对应字段。

DeepSeek Harness 路径二:客户端超时加一次重试

这条路只适合插件作者做临时兜底,不该当成修复。 讨论里的报告者给自己的插件加了 10 秒 AbortSignal.timeout 与一次自动重试,确实能自愈,但每次要浪费 10 到 20 秒;写这类面板插件时,可以在 DSH Plugin Hub 对照其他插件的超时处理:

  1. 给面板里的 fetch 统一加一个 10 秒的 AbortSignal.timeout。预期结果:挂起不再无限持续,10 秒后抛超时错误。
  2. 捕获超时后换新连接重试一次,且只对幂等请求重试。预期结果:面板最终能加载,但首屏明显变慢。
  3. 在重试前打一条带 URL 与耗时的日志。预期结果:能统计出每次空闲后要多久才恢复,用来判断值不值得继续保留这层兜底。

注意非幂等的插件 RPC 不要自动重试,否则可能出现重复写入。服务端修好后,这层兜底应当删掉,而不是长期共存。

重试次数控制在一到两次就够。每次重试都会新建连接,次数多了只会把 10 秒的挂起变成 30 秒的等待,还会掩盖服务端超时未配置这个事实。

DeepSeek Harness 路径三:反代侧对齐空闲超时

当 dsh web 挂在 nginx、Caddy 或公司网关后面时,反代会先于宿主回收连接。 需要把上游空闲时间设得比反代自己的长:

  1. 查反代的空闲超时配置。nginx 看 keepalive_timeout 与 proxy_read_timeout,Caddy 看 timeouts 块。预期结果:拿到具体秒数,和宿主的新值对比。
  2. 把上游连接的空闲超时设为大于宿主 keepAliveTimeout 的值,并确保使用 HTTP/1.1 上游连接。预期结果:proxy_http_version 1.1 生效,Connection 头不再被写死为 close。
  3. 重载反代配置,重复空闲复用测试。预期结果:经过反代的请求也不再挂起。
  4. 如果反代无法调整,退而求其次把宿主的 keepAliveTimeout 调到小于反代阈值。预期结果:连接由宿主先关,至少不会出现反代把半死连接转发给上游。

反代路径最常见的坑是只改了宿主、没改反代,于是症状从「5 秒后挂」变成「60 秒后挂」,看起来像修好了,其实只是把边界推远了。

DeepSeek Harness 改完怎么确认真的修好

验证标准只有一条:空闲超过原阈值后,复用连接仍然拿到完整响应。 按顺序做:

  1. 用裸 socket 脚本分别在 6 秒、30 秒、55 秒后复用连接。预期结果:三次都返回 200;55 秒那次成功,说明新的空闲阈值已经覆盖到 60 秒以内。
  2. 打开浏览器开发者工具的 Network 面板,观察空闲后的第一个请求。预期结果:状态 200,没有 (failed) 或长时间 pending。
  3. 看服务端是否还有 CLOSE_WAIT 堆积。预期结果:连接数稳定,不随页面空闲时间增长。
  4. 用 curl --no-keepalive 连续请求面板接口。预期结果:每次都是新连接、每次都成功,进一步说明问题只在连接复用上。

排查时容易误判的两处,顺手记一下:一是以为浏览器不知道连接要过期,实际上 Keep-Alive: timeout=5 一直在响应里;二是以为 gzip 拖累了响应,宿主的压缩默认是 none,gzip 由 bundle 打开且与超时无关。

据讨论,这个缺陷目前在宿主侧未修,报告者的插件内补丁可用,说明根因判断成立。别把它和 gzip 流式缓冲混在一起:那是响应写出后停在 zlib 缓冲区,症状是画面一跳一跳,不是永久 loading。

来源:Discussion #10106、deepseek-ai/deepseek-harness。Web 鉴权与端口相关的排查另见 DSH Web 鉴权与端口。

常见问题

DeepSeek Harness web 面板放几秒不动就转圈,刷新又好了,是什么原因?

DeepSeek Harness 的 keep-alive 空闲超时用的是 Node 默认 5 秒。服务端在 5 秒后关闭空闲连接,浏览器恰好在关闭瞬间复用这条连接发请求,请求字节被正在拆除的 socket 接收却不再产生响应,前端的 fetch 就一直等不到 settle。新连接永远正常,所以看起来像面板坏了。

DeepSeek Harness 调大 keepAliveTimeout 后,为何还要设 headersTimeout?

DeepSeek Harness 的 headersTimeout 必须大于 keepAliveTimeout,否则连接刚熬过空闲期、下一轮请求头还在路上时就被 header 超时抢先掐断。两个值目前在宿主仓库里都没有设置,必须在同一次改动里一起加,常用组合是 60000 与 65000。

给 DeepSeek Harness 面板加 10 秒超时和一次重试,能解决永久 loading 吗?

不能,DeepSeek Harness 面板加超时重试只是绕过。重试会把请求挪到新连接,所以看起来恢复了,但每次都要先浪费 10 到 20 秒,而且非幂等的插件 RPC 重试可能产生重复副作用。根治要在服务端把空闲超时提到浏览器阈值之上。

DeepSeek Harness web 挂在 nginx 后面时,只改 keepAliveTimeout 够吗?

DeepSeek Harness 挂在 nginx 后面时,只改 keepAliveTimeout 不够。反代有自己的空闲超时和上游连接池,nginx 的 keepalive_timeout 或 proxy_read_timeout 小于上游的 keepAliveTimeout 时,连接会先被反代回收。两边要把上游空闲超时设得比反代更长,并确认 proxy_http_version 1.1 与 Connection 头配置正确。

相关术语

keepAliveTimeout
keepAliveTimeout 是 HTTP 服务端在一条 keep-alive 连接上等待下一个请求的最长时间,超时后服务端主动关闭连接,Node 的默认值是 5000 毫秒。— https://github.com/deepseek-ai/deepseek-harness/discussions/10106
headersTimeout
headersTimeout 是服务端等待完整请求头到达的时限,必须大于 keepAliveTimeout,否则连接刚过空闲期就会被头部超时提前终止。— https://github.com/deepseek-ai/deepseek-harness/discussions/10106
close/send race(关闭与发送竞态)
close/send race 指客户端在一个即将被服务端关闭的 socket 上写入请求,字节被接收但不会再有响应,表现为请求挂起或连接重置。— https://github.com/deepseek-ai/deepseek-harness/discussions/10106
Keep-Alive: timeout=5
Keep-Alive 响应头是服务端告知客户端空闲连接还能保留多久的字段,Node 会在每个 keep-alive 响应里带上它,浏览器据此提前淘汰连接池里的旧 socket。— https://github.com/deepseek-ai/deepseek-harness/discussions/10106

来源