WebSSH 死通道判定窗口与传输尾部关会话止损实施计划
WebSSH 死通道判定窗口与传输尾部关会话止损实施计划
状态:补记计划(原始实现已完成)
补记说明:本计划对应一次线上缺陷止损。用户报告“sz 下载中偶现 ssh 关闭”,页面进入“已断开 / SSH 通道已关闭”空态,并明确要求“检测通道时间太短,30s 以上才认为通道关闭”。按 AGENTS.md 的紧急修复条款先修复再补记;红灯证据为实现前的真实观测,门禁结果在补记时重新执行,见第 7 节。
1. 目标
- 把浏览器侧“通道已死”的判定窗口从 5 秒零进度提升到 30 秒零进度,shell 写与 SFTP 写共用同一常量。
- 移除“传输已结束但协议写仍在途”的迟到拒绝关闭整条会话的路径;通道存活性归读循环与按键路径裁决。
2. 非目标
- 不修改 Server/Agent/relay 的 Go 代码:全链路盘点后,Server 侧唯一的时间型判定是
server.webssh.idle_timeout,默认 5 分钟(见internal/config/config.go默认表),已远大于 30 秒,无需调整。 - 不给第十五轮引入的无期限协议写加封顶:lrzsz 自身重传超时远短于任何合理封顶值,加封顶只会重新引入“传输中途被判死”。
- 不实现 WebSocket 自动重连:ticket 一次性消费,重连等价于新会话,超出本轮范围。
- 不新增配置项:判定窗口与流控额度同属内置策略,暴露参数会造成线上各节点行为漂移。
3. 根因与证据
截图空态的渲染条件是 status === 'closed' && !remoteEnded,且没有红色传输告警、没有“远端会话已结束”块,因此只能由 closeTerminal()(非 keepTerminal 分支)产生。传输期间按键被丢弃、toPeer 的拒绝走 abortLocal 分支,于是只剩两条路径:
- 5 秒停滞保护把拥塞当死亡。
writeStallLimit = 5000是浏览器侧唯一的亚 30 秒时间型判定(shell 写与 SFTP 写共用)。大文件sz的尾部会让出站窗口拥塞数秒以上:传输结束瞬间用户敲键,按键写排在仍在等窗口额度的协议写之后,轮到它时窗口仍满,5 秒零进度即抛SSH shell write stalled after N of M bytes,按键路径的 catch 直接closeTerminal(),页面进入空态。SFTP 上传共用同一常量,同样偏激。 - 在途协议写的迟到拒绝关会话。 第十五轮让
toPeer的写以stallLimitMs: 0无期限等待窗口额度;传输因ended/aborted结束后,这条写可能仍在 gate 中。它随后若拿到 libssh2 负值错误而拒绝,toPeercatch 的“非活跃”分支会closeTerminal()——一次与 shell 健康无关的迟到拒绝,杀掉了活着的会话。
证据:
- 时间型判定盘点:浏览器侧仅
writeStallLimit = 5000(shell 与 SFTP 共用);Server 侧仅server.webssh.idle_timeout,默认 5m;其余保护(64 MiB 接收队列、64 MiB WebSocket 背压)是容量型而非时间型。唯一亚 30 秒者即 5 秒停滞保护,与用户“检测通道时间太短”的判断一致。 - 红灯测试
only reports a stalled shell write after thirty seconds of zero progress:修复前 20 秒处failure已为SSH shell write stalled after 0 of 3 bytes。 - 红灯测试
fails an SFTP write only after thirty seconds of zero progress:修复前 20 秒处已抛SFTP write stalled after 0 of 64 bytes。 - 红灯源码断言
fails the transfer instead of the channel when a protocol write fails:修复前toPeer片段包含closeTerminal()。
4. 架构决策
- 判定窗口即策略常量:
writeStallLimit = 30_000,注释写明它是“通道已死”的判定而非延迟预算。真正死掉的隧道仍会由 WebSocket close 与读循环更早 surfaced,30 秒只是兜底上限。 - 存活性裁决权收敛:
toPeer的 catch 只负责“传输活跃则中止传输并提示”,非活跃分支不再关会话,理由与第十四轮一致——通道存活性归读循环。按键路径保留“零进度达到判定窗口即关终端”,因为按键写不出字节且读循环也无数据时,会话确实已死。 - 不引入新参数:窗口是内置策略,与流控额度一样不提供配置面,避免集群内节点行为不一致。
5. 文件清单
web/src/webssh/ssh-client.ts:writeStallLimit由 5000 改为 30_000,并补策略注释。web/src/views/WebSSHTerminal.vue:toPeercatch 的非活跃分支移除closeTerminal(),改为说明性注释。web/src/webssh/ssh-client.spec.ts:两个停滞用例重写为“20 秒不报、35/36 秒报”。web/src/tests/zmodem-terminal.spec.ts:源码断言改为toPeer片段不得包含closeTerminal()。web/dist/、internal/server/web_dist/:重新构建并同步的嵌入产物。- 文档:本文件、
docs/operations/troubleshooting.md增补一节、docs/pull-requests/2026-09-12-admin-webssh-sftp.md第十六轮小节与 Reviewer 关注点。
6. TDD 步骤
红灯(实现前 npx vitest run 两个 spec,结果 3 failed / 44 passed):
only reports a stalled shell write after thirty seconds of zero progress→ 20 秒处expected Error: SSH shell write stalled after 0 of 3 bytes to be null。fails an SFTP write only after thirty seconds of zero progress→ 20 秒处expected SFTPError: SFTP write stalled after 0 of 64 bytes to be null。fails the transfer instead of the channel when a protocol write fails→expected 'toPeer: (bytes) => {…' not to contain 'closeTerminal()'。
最小实现:第 4 节两条,未做超出目标的重构。
绿灯:定向 spec 60/60;全量 30 文件 / 234 用例;E2E 15/15。
7. 验证命令与结果(补记时重新执行)
cd web && npx vitest run src/webssh/ssh-client.spec.ts src/webssh/zmodem.spec.ts src/tests/zmodem-terminal.spec.ts
cd web && npm test -- --run && npm run build
rsync -a --delete web/dist/ internal/server/web_dist/ && ./scripts/verify-web-embed.sh
TM_E2E_DIR=/tmp/tm-e2e-r16 node test/e2e/webssh/run.mjs
go build ./... && go vet ./... && gofmt -l internal/ cmd/ test/ && git diff --check
go test ./... -count=1
结果:
- 定向 spec 60/60 通过;前端全量 30 文件 / 234 用例通过(用例数与上一轮持平:重写 3 个、未新增)。
npm run build成功;verify-web-embed.sh输出web/dist and internal/server/web_dist match。- E2E 15/15 PASS(工作目录
/tmp/tm-e2e-r16):4 MiBsz、1.5 MiBrz、3 MiB SFTP 均逐字节校验,浏览器控制台洁净。 - Go 门禁:
go build ./...、go vet ./...、gofmt -l internal/ cmd/ test/、git diff --check均无输出;go test ./... -count=1FAIL 计数 0,18 个包 ok、5 个包无测试文件。本轮未改任何 Go 代码。
8. 发布与回滚注意事项
- 修复全部在前端 bundle:发布需
npm run build+rsync到internal/server/web_dist/后重建 Server 二进制。 - 回滚即回退 bundle;回滚后恢复 5 秒判定与“迟到拒绝关会话”两条旧行为。
- 行为变化需知会使用者:真正死掉的隧道在最坏情况下要等 30 秒零进度才由写路径 surfaced;通常 WebSocket 关闭或读循环 EOF 会更早给出结束态,30 秒只是兜底。
- Server 侧无需改配置;若曾为排查问题把
server.webssh.idle_timeout调小,请恢复默认(5m)或保证不小于传输最久暂停时间。 - 已知残留:第十五轮的无期限协议写在传输结束后仍可能短暂占住写闸门,排在其后的按键写需等它退场;断开连接不受影响(
close()不排队)。若后续观测到“传输结束后敲键无回显但页面仍显示连接”,再为无期限写引入可取消语义,不在本轮扩大范围。