DeepSeek Harness web 面板永久 loading:keepAliveTimeout 超时排查
页面空闲几秒后插件面板永久转圈,不是前端状态机坏了,而是请求写进了一条正在被服务端关闭的 keep-alive 连接。 dsh-host-webserver 创建 HTTP 服务时没有设置 keepAliveTimeout 与 headersTimeout,Node 默认的 5 秒空闲超时生效;浏览器恰好在边界上复用连接,就会撞上 close/send 竞态,请求既不返回也不报错(Discussion #10106)。
DeepSeek Harness web 面板挂起复现:连接空闲 8 秒后再发请求
用裸 socket 就能把「服务端正常、浏览器挂起」拆开。 讨论里的复现脚本在 dsh web 的端口上跑两次请求:
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)
- 跑第一段请求,确认新连接立即返回 200。预期结果:状态码正常,连接进入 keep-alive 池。
- 睡眠 8 秒后复用同一连接再请求。预期结果:抛
ConnectionResetError或一直等到自己的 10 秒超时。 - 用
lsof -nP -iTCP:3080看连接状态。预期结果:出现CLOSE_WAIT,即服务端已关闭而客户端尚未察觉。 - 换一条全新连接请求同一个路径。预期结果:立刻成功,证明服务端本身没有卡死。
第 4 步是关键判据:新连接永远正常,只有复用空闲连接才会挂,这就是连接层而不是业务层的问题。
DeepSeek Harness 机制修正:浏览器并非毫不知情
真正导致永久 loading 的不是「客户端不知道连接要关」,而是关闭与发送撞在同一时刻。 讨论回复里做了两点澄清:
- Node 会在每个 keep-alive 响应里带
Keep-Alive: timeout=5,Chrome、Firefox、Safari 都据此提前淘汰空闲 socket,所以浏览器不是盲的。 - 能产生「promise 永不 settle」的是 close/send race:客户端写入的瞬间服务端定时器触发,字节被一条正在拆除的连接接收,没有响应生成,客户端只能等到自己的超时。
- 因此修法仍然是设置超时,但必须满足
headersTimeout > keepAliveTimeout。Node 不强制这个关系,语义强制。 - 压缩不是宿主默认行为,
DEFAULT_COMPRESSION是none,gzip 由 bundle 层打开,所以改超时不会和压缩互相影响。
这也解释了为什么「刷新一下就好」:刷新会建立新连接,绕开了那条正在死亡的 socket。
这两条澄清不改变修法,但改变了验证方式:不要用「客户端有没有弹超时」判断是否修好,要看同一条空闲连接复用后能不能拿到完整响应。客户端侧的错误提示只能说明它等到了自己的超时,和连接是否健康无关。
DSH插件路径一:在服务端同时设置两个超时
这是唯一根治的方向,两个值必须一起设,且 header 超时更大。 在 createServer 之后、listen 之前加:
server.keepAliveTimeout = 60_000;
server.headersTimeout = 65_000; // 必须大于 keepAliveTimeout
- 确认当前宿主版本里全仓搜不到这两个配置项。预期结果:
keepAliveTimeout与headersTimeout都是 0 命中,说明用的确实是 Node 默认值。 - 在 webserver 创建服务实例的位置补上两行赋值。预期结果:服务正常启动,不报参数错误。
- 重启
dsh web,重复上一节的空闲 8 秒复用测试。预期结果:第二次请求正常返回 200,不再重置或挂起。 - 把页面放 30 秒不操作,再打开一个插件面板。预期结果:面板正常加载,不需要刷新。
如果你的宿主版本已经把这两个值暴露成配置项,优先改配置而不是改源码;判断依据是配置 schema 里有没有对应字段。
DeepSeek Harness 路径二:客户端超时加一次重试
这条路只适合插件作者做临时兜底,不该当成修复。 讨论里的报告者给自己的插件加了 10 秒 AbortSignal.timeout 与一次自动重试,确实能自愈,但每次要浪费 10 到 20 秒;写这类面板插件时,可以在 DSH Plugin Hub 对照其他插件的超时处理:
- 给面板里的
fetch统一加一个 10 秒的AbortSignal.timeout。预期结果:挂起不再无限持续,10 秒后抛超时错误。 - 捕获超时后换新连接重试一次,且只对幂等请求重试。预期结果:面板最终能加载,但首屏明显变慢。
- 在重试前打一条带 URL 与耗时的日志。预期结果:能统计出每次空闲后要多久才恢复,用来判断值不值得继续保留这层兜底。
注意非幂等的插件 RPC 不要自动重试,否则可能出现重复写入。服务端修好后,这层兜底应当删掉,而不是长期共存。
重试次数控制在一到两次就够。每次重试都会新建连接,次数多了只会把 10 秒的挂起变成 30 秒的等待,还会掩盖服务端超时未配置这个事实。
DeepSeek Harness 路径三:反代侧对齐空闲超时
当 dsh web 挂在 nginx、Caddy 或公司网关后面时,反代会先于宿主回收连接。 需要把上游空闲时间设得比反代自己的长:
- 查反代的空闲超时配置。nginx 看
keepalive_timeout与proxy_read_timeout,Caddy 看timeouts块。预期结果:拿到具体秒数,和宿主的新值对比。 - 把上游连接的空闲超时设为大于宿主
keepAliveTimeout的值,并确保使用 HTTP/1.1 上游连接。预期结果:proxy_http_version 1.1生效,Connection头不再被写死为close。 - 重载反代配置,重复空闲复用测试。预期结果:经过反代的请求也不再挂起。
- 如果反代无法调整,退而求其次把宿主的
keepAliveTimeout调到小于反代阈值。预期结果:连接由宿主先关,至少不会出现反代把半死连接转发给上游。
反代路径最常见的坑是只改了宿主、没改反代,于是症状从「5 秒后挂」变成「60 秒后挂」,看起来像修好了,其实只是把边界推远了。
DeepSeek Harness 改完怎么确认真的修好
验证标准只有一条:空闲超过原阈值后,复用连接仍然拿到完整响应。 按顺序做:
- 用裸 socket 脚本分别在 6 秒、30 秒、55 秒后复用连接。预期结果:三次都返回 200;55 秒那次成功,说明新的空闲阈值已经覆盖到 60 秒以内。
- 打开浏览器开发者工具的 Network 面板,观察空闲后的第一个请求。预期结果:状态 200,没有
(failed)或长时间 pending。 - 看服务端是否还有
CLOSE_WAIT堆积。预期结果:连接数稳定,不随页面空闲时间增长。 - 用
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 的 keep-alive 空闲超时用的是 Node 默认 5 秒。服务端在 5 秒后关闭空闲连接,浏览器恰好在关闭瞬间复用这条连接发请求,请求字节被正在拆除的 socket 接收却不再产生响应,前端的 fetch 就一直等不到 settle。新连接永远正常,所以看起来像面板坏了。
DeepSeek Harness 的 headersTimeout 必须大于 keepAliveTimeout,否则连接刚熬过空闲期、下一轮请求头还在路上时就被 header 超时抢先掐断。两个值目前在宿主仓库里都没有设置,必须在同一次改动里一起加,常用组合是 60000 与 65000。
不能,DeepSeek Harness 面板加超时重试只是绕过。重试会把请求挪到新连接,所以看起来恢复了,但每次都要先浪费 10 到 20 秒,而且非幂等的插件 RPC 重试可能产生重复副作用。根治要在服务端把空闲超时提到浏览器阈值之上。
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
来源
- deepseek-harness Discussion #10106:keep-alive connections killed at node-default 5s cause requests to hang forever· deepseek-ai(GitHub Discussions)
- deepseek-ai/deepseek-harness 官方仓库· GitHub