把终端字节流送到 xterm.js:一条回环 WebSocket 的取舍

ssh-gui 技术系列 · 三 · 2026-10-23

终端是 ssh-gui 的主角,而主角的命脉是一条字节流:远端 shell 的输出要实时、完整、有序地出现在 xterm.js 的画布上,用户的每一次击键要原样送回去。这条链路看起来简单,实际上是我们整个项目里做方案对比做得最狠的一处。这篇把它从头讲到尾。

问题:事件通道传不了字节流

桌面外壳的常规做法是用框架自带的事件系统在 Go 和前端之间传数据。Wails 的事件通道是 JSON 序列化的,拿它传终端输出会遇到三重损耗:

[]byte 进 JSON 会被编码成 base64 字符串,体积凭空膨胀 1.33 倍;每条事件一次跨语言调用加一次编解码,tail -f 这种每秒几 MB 输出的场景下,派发开销直接把延迟从毫秒级顶到秒级;最要命的是事件是 fire-and-forget 的,没有背压语义——前端处理不过来时,没有任何机制告诉 Go 侧"慢一点",只会无限堆积。

终端输出的特点是一个字节都不能丢。丢的不是几个字,是 ANSI 转义序列的完整性——序列断了,屏幕的颜色、光标位置、整行状态全部错乱,而且错乱之后无法自愈。所以结论很早就有:需要一条真正的字节流通道,而不是消息通道。

方案:进程内回环 WebSocket

最终选的是让 Go 进程自己起一个 HTTP 服务,监听 127.0.0.1:0——随机空闲端口,只绑回环。前端连上去,路径里带会话 ID:

ws://127.0.0.1:<随机端口>/v1/pty/<sessionId>?token=<一次性令牌>

这条连接上跑两种帧。二进制帧透传 PTY 字节,零转换零膨胀;文本帧走控制消息,比如 {"t":"resize","cols":120,"rows":32}、{"t":"exit","code":0}、断线时的 {"t":"state","state":"disconnected"}。方向由连接语义天然确定——前端发给 Go 的二进制帧必然是键盘输入,反方向必然是终端输出——所以连帧头前缀都省了。

本地端口是新增的攻击面,补偿控制做了三层:令牌是启动时生成的 32 字节随机值,只通过内部绑定接口下发给前端,不落盘、不进 localStorage;端口只绑 127.0.0.1,其他机器扫不到;握手时先校令牌,不对就 403,不进入任何业务逻辑。会话 ID 本身是随机 UUID,猜不出来。

背压:终端输出不可丢

传输层的背压策略是这条链路里我们最坚持的一个决定。

Go 侧给每个会话维护订阅者队列,前端消费慢、队列塞满时,输出泵阻塞读取——不是丢弃,是停下来。SSH 协议的通道窗口机制会自然把这份阻塞传导回远端 sshd,远端 shell 的输出节奏随之放慢。整条链路形成闭环,不丢一个字节。

只有一种情况走丢弃:超出硬上限还消费不动(终端被 cat 了一个巨大的二进制文件,而用户把窗口最小化了)。这时记录丢弃计数、在状态栏提示,宁丢不崩。崩溃对一个终端客户端来说是所有失败里最不可接受的那种。

断线与重连:同一个标签活过来

会话的结局分两种。远端 shell 正常退出、用户主动关标签,记为 closed,不可重连;EOF、连接重置、异常关闭,记为 disconnected,保留在会话表里等重连。

重连是原地复用会话 ID:Go 侧重新准备认证材料、重新拨号、重建 PTY,回看缓冲原样保留,前端按 2s, 5s, 15s, 30s 的退避序列自动尝试。用户看到的是断线的标签自己恢复,滚动历史还在。

这里有个容易被忽略的细节:重连后前端要重新订阅输出流,但此时 xterm 画布上已经保留了断线前的内容,如果服务端从缓冲起点回放,所有历史会被写第二遍;如果完全不回放,重连后新出现的提示符又会丢。解法是服务端在会话里维护一个 mark,指向最近一次重连点在回看缓冲中的位置,重订阅时带 ?replay=0,服务端只回放 mark 之后的内容——既不重复,也不丢新数据。缓冲因超过 1 MB 上限被裁剪时,mark 同步左移,边界永远有效。

还有一个纯产品判断:口令认证的主机不自动重连。因为口令在 Go 侧来自系统凭据对话框,自动重连意味着每次退避都弹一次框,用户在开会时回来看到十个凭据框。所以口令主机的重连按钮留给用户手动点。机器判断不了的事,交给人的判断。

重启恢复:快照里没有会话 ID

比断线更彻底的是应用重启。ssh-gui 的做法是把标签布局——每个面板里有哪些主机、哪个面板聚焦、分屏比例——在用户操作后防抖 800 毫秒写入 SQLite 的一张单行表。下次启动时读这份快照重建标签。

注意快照里存的是 hostId,不是会话 ID。会话 ID 是内存态,进程死了就失效,任何"复活旧会话"的尝试都是自欺。恢复的本质是重新建会话:快照只是告诉你"上次开着这些机器的终端"。

逐台判定也做了区分:密钥主机且保险库已解锁的,启动后直接拨号;凡是拨号会向用户要凭据的——口令主机、加密私钥、保险库锁定时的密钥主机——一律建成灰色的「待连接」占位标签,写明原因,用户点「连接」才弹系统凭据框。启动过程不弹任何对话框。这条规矩和"口令主机不自动重连"是同一条哲学:应用不能替用户决定什么时候面对一排密码框。

已知限制

如实登记几条:传输层目前没有降级通道,回环端口被企业策略禁掉时终端会明确报错而不是自动切换;跳板机连接不跨目标复用,两台主机共用同一个堡垒机时各持一条链(OpenSSH 的 ControlMaster 语义没有做);心跳只发往链路末跳,堡垒机被中间设备切断时按整链断开处理,重连重建整条链。这些都在架构文档里挂着编号,属于"知道、接受、留了记录"的状态。

终端链路的最后一环是 xterm.js 本身——WebGL 渲染失败时回退 DOM 渲染器、ResizeObserver 触发尺寸同步、输入经 onData 原样进 WS——这些属于集成层面的功夫,没有太多可争议的取舍,就不展开了。真正值得记下来的,是"字节流要走回环 WebSocket"、"背压靠阻塞不靠丢弃"、"重连从 mark 回放"这三个决定,它们共同保证了终端在任何状况下都表现得像一台真的终端。

posted @ 2026-10-23 23:35  cliffdev  阅读(5)  评论(0)    收藏  举报