Skip to content

0.8.45 — 修掉一个会杀掉整个 DSH 进程的崩溃 + 远程会话的文件链接能打开了

Choose a tag to compare

@flymysql flymysql released this 16 Oct 04:55
· 95 commits to main since this release

连接池失效不再把整个 host 进程带走(issue #54)+ 远程会话的文件链接能打开了(issue #55)+ 文档回答「容器当工作空间」(issue #56)

1)致命崩溃:SSH 连接池失效后,隧道上的任意请求都会杀掉 host 进程(issue #54)

现象是整个 DSH host 进程退出(不是"隧道断开"):当前会话全丢、浏览器里所有页面失联,
而且远端的 dsh web 变成没人回收的孤儿进程,反复崩溃会不断累积。稳定复现:

dsh: fatal uncaught exception: Error: Not connected
    at Client.forwardOut (.../ssh2/lib/client.js:1400:13)
    at WebAttach._pipe (.../dsh-remote/lib/web-attach.js:404)
    at Server.<anonymous> (.../dsh-remote/lib/web-attach.js:376)
    at TCP.onconnection (node:net:2310:8)

两个缺陷叠加,缺一不可:

  • forwardOut 是同步抛出的,而调用处只处理了回调错误。 ssh2 在连接已死时
    throw new Error('Not connected')(if (!this._sock || !isWritable(this._sock))),
    回调根本不会被调用,所以 if (err) { socket.destroy(); return } 这条兜底对这条路径完全无效。
    异常从 net.Server 的 connection 回调一路冒到进程顶层。修法:把 forwardOut 包进
    try/catch,接住后只把隧道标成不可用(lastError)并销毁 socket —— 崩在"隧道不可用"上是把
    一个可降级的 UI 状态当成了"进程该退出"。lib/openssh.js(issue #51 的系统 OpenSSH 后端)
    在连接已关闭时 spawnSsh() 也是同步抛出,属同类风险,同一层兜住。

  • WebAttach 借用了池的连接,却从未订阅它的生命周期。 池失效时只清自己的引用
    (pool.js 的 close 处理器与 invalidate()),WebAttach.client 永远是那个死句柄,
    于是下一次请求必然走到上面那条同步抛出的路径。仓库里 ForwardManager 早就做对了
    (forwards.js 注册 client.on('close', …)),所以这是遗漏而非设计。
    修法:_attach() 登记 close 监听(幂等、换客户端时先摘旧监听,close() 里摘干净),
    并按 ForwardManager 的范式接上池的 onReady / onCloseHook。

1b)同一缺陷还有两个调用点,一并修掉(grep -n forwardOut lib/*.js)

同步抛出是 ssh2 这个库的性质,不是 WebAttach 的性质 —— 三个调用点里另外两个同样可达:

  • forwards.js 的本地端口转发(_startLocal):它的 net.Server connection 回调就是
    issue #54 那条致命栈本身。attach() 确实登记了 close → stopAll(),但那只能救"close 事件
    先于请求到达"的时序;先来一个请求照样崩。现已走统一的 forwardOutChannel()。
  • pool.js 的 ProxyJump 通道(pclient.forwardOut):它在 new Promise executor 里裸调。
    executor 里的 throw 不会 reject 这个 promise,而是变成 unhandled rejection ——
    同样致命,而且更难归因(连接会永远 pending)。跳板机连接已死时就是这个场景。

为此把「调一次 forwardOut 并拿到 channel」抽成导出的 forwardOutChannel(client, spec),
三个调用点共用一份带 try/catch 的实现(回调错误与同步抛出都变成普通的 reject)。

2)顺带把"崩溃"升级成"自愈":隧道跟着连接池走

池是唯一既发现连接已死、又拿到新连接的地方,它正好暴露两个钩子:
onCloseHook(在 close() / invalidate() 里触发 —— 后者就是"channel open 失败,
连接在服务端已死"的自愈路径,也是本次最可能的触发点)与 onReady(每次连上后触发)。
这两个槽位是单槽属性、插件已经用它们挂 ForwardManager,所以本会话是串接
(转发给原钩子)而不是覆盖,_detachPool() 只在自己装的那个包装还在槽位里时才还原 ——
别的拥有者接管了槽位就绝不抢回来。

重连后远端端口可能已经变了(--port 0 是远端挑的,连接断开时进程可能一起死了),
所以 onReady 会重新解析 (token, port):先读本会话的远端日志,再用
/proc/<pid>/fd 的 socket inode 去 /proc/net/tcp{,6} 里查该进程实际 LISTEN 的端口 ——
日志里会有两条启动行(死掉的 + 重启的),位置判断不了哪条是活的,实证才能。
进程真死了就重启它(只重启本会话自己启动的进程:挂到别人已在跑的实例上时一律不动)。

3)体检补上 node 最低版本(issue #54 附带问题)

"远端 dsh 体检"以前只问 node 在不在、不校验版本,于是在 node 18 的机器上判定
can auto-install: yes。安装会成功(npm 只给 EBADENGINE 警告、不拦截,535 个包照装),
崩在启动时:

SyntaxError: The requested module 'node:util' does not provide an export named 'parseEnv'

而这条错误读起来完全不像"node 版本太低"。现在按 Node ≥ 20.12 判定 blocker
(parseEnv 从 20.12.0 引入,21.7.0 也有),并给出去向:升级 node,
把 webAttachCommand 指向新 node 的绝对路径。版本读不到时不报 ——
凭缺失的证据拒绝安装,会弄坏今天本来能用的机器。

4)webAttachCommand 终于有输入框了(同一 issue 的文档缺口)

README 一直写着"远端 dsh 不在 SSH 登录 PATH 时用 webAttachCommand 指向绝对路径",
但设置页里没有这个字段(grep webAttachCommand lib/client.js → 0 处),
只能靠"部署并验证"成功时自动写入,或手改 machines.json —— README 也没说改哪个文件。
现在它是机器的普通字段:设置页「高级」→「远端 dsh 命令」(机器级优先于全局),
rw_connect 也能传。

验证:新增 29 个单测(test/issue54-stale-client.test.js 22 个 +
test/issue54-webattach-command.test.js 6 个 + web-deploy 的 node 版本判定),
全量 548/548 通过;node check.mjs 闸门通过。
崩溃修复的断言方式是"进程活着"本身:用会同步抛出的 client 驱动
net.Server 的 connection 回调,assert.doesNotThrow 就是原始失效模式的对照。

文档:远程主机上的 docker 容器怎么当工作空间(issue #56)

需求是「把远程主机上的 docker 容器当工作空间」。核对后不需要改任何代码 ——
插件对容器零感知(全仓 docker/container/podman/kubectl/nsenter 扫描只命中
lib/ignore.js 的一条 ~/.docker 搜索忽略与几处截图版式字段),但两条既有配置已经完整覆盖:

  • 容器映射了 sshd 端口(docker run -p 127.0.0.1:2222:22)⇒ 纯「跳板机」配置。
    关键在 proxy 的语义就是「在跳板机上连 <host>:<port>」
    (lib/pool.js 的 pclient.forwardOut(...this.config.host, this.config.port...)),
    拿到的通道被当作主连接自己的 transport(opts.sock)⇒ SFTP 走容器自己的 sshd,
    rw_list_dir/rw_read_file/rw_write_file/rw_sync 读写的都是容器内文件。
  • 容器没有暴露端口 ⇒ 纯 ~/.ssh/config:ProxyCommand ssh <主机> docker exec -i <容器> sh -c 'exec socat - TCP:127.0.0.1:22'
    • backend: openssh。openssh 后端把别名原样交给 ssh 且刻意不生成自己的 ProxyCommand
      (lib/openssh.js,就是为了让 ~/.ssh/config 说了算),而 ssh -s <目标> sftp 与
      ssh -- <目标> <命令> 走同一条流 ⇒ 命令与文件操作都进容器。

为什么没有走「给 exec 套一层 docker exec」这条捷径:那会造成脑裂 ——
SFTP 是 ssh2 的子系统通道(native)/ ssh -s sftp(openssh),
远端的 sftp-server 由 sshd 在宿主命名空间里拉起,没有任何 shell 包装能改变这一点,
于是 17+ 处文件操作(rw_sync/rw_push/侧栏读文件/@ 补全/自动推回)会继续读写宿主。
真要实现必须换掉唯一的那道缝 buildSftpSurface,那就不是"小改"了。

把「Windows 自带 OpenSSH 不展开 ProxyCommand」的实测结论写进了 README
(POSIX OpenSSH 会交给 shell,Windows 9.5 当字面 argv),并标注 docker exec -i 的二进制保真度
未验证(本机没有 docker)—— 不能用没验证过的结论冒充结论。

远程会话里的文件链接现在能打开了(issue #55)

1)根因:链接交给了读「本机」文件的预览器

DSH 把消息里的 [x](src/main.c)、@src/main.c 都渲染成按钮,点下去由 chat 转成
dsh-resource://file/session/<会话>/<路径>,再交给侧栏的地址路由。唯一认领这个地址的是内置文本预览,
它按会话自己的 cwd 去读本机的 ctx.fs;而远程会话的 cwd 正是本插件建的本地镜像目录
(rw_sync 之前是空的)⇒ 打开必然失败。

2)修法:认领同一个地址,但用 SFTP 打开

不能改成"教内置预览走 SSH":ctx.resources 每个协议只允许一个提供者,而 file 已被占用;
也不能改核心包。可用的缝是再注册一个 tab 类型去认领同一个地址 ——
按契约 extension 频段优先级高于产品自带的 fallback,所以不声明 priority 即可压过内置预览。
正文复用侧栏「远程文件」同一个编辑器(RemoteFileTab),只是把路径先解析成远端绝对路径。

3)安全边界才是重点:canOpen 必须保守

这是本次唯一有破坏性的风险面 —— 一个 extension 类型对所有会话生效,
只要它错认了本地会话的链接,全产品的本地文件链接就会被打到 SFTP 上去。
所以 canOpen 只在两条同时成立时返回 true:该会话的 cwd 确实落在某个镜像目录里
(复用已有 mirrorForCwd,它按分隔符判定,/a/mirror 不会被 /a/mirror-2 误命中),
且镜像表已加载。空表证明不了任何事,于是冷启动一律不接管,退回内置预览(行为与改动前完全一致);
未知的会话 id 也一律拒绝(否则一个伪造链接就能借某个镜像去读任意远端路径)。
映射不出来时给明确提示,而不是渲染一个空编辑器。

4)第二个诉求(「终端也应该是远程的」):如实回答,没有硬凑

DSH 的侧栏终端是核心功能:PTY 来自全局唯一的 subprocess 服务,
cwd 取会话自己的工作区目录(远程会话就是本地镜像)。插件既不能替换那个服务、也不能把核心终端重定向到 SSH,
所以没有去注册一个假的 kind:'terminal'(有一条单测把这个"不要做"钉住)。
等效方案写进了 README:用「远程 DSH 界面」(issue #46)打开远端机器自己的 DSH,
那个界面里的终端真的跑在远端;只跑单条命令则用 rw_exec(要交互式加 pty: true)。

验证:新增 16 个单测(test/issue55-remote-file-links.test.js),其中 5 条是负向对照
(非 session 地址 / 本地会话 / 冷启动 / 未知会话 / 无法映射),并测了正文真正产出的远端绝对路径
(相对链接接到远端根、镜像前缀被剥掉而不是重复拼接、已是绝对路径的原样保留、Windows 形态的盘符路径)。
全量 567/567 通过。