一条看起来还活着的死连接——Shellby 的心跳令牌
Shellby 复盘:SSH 连接静默挂起时,「命令能返回」根本不代表连接还活着。
Shellby 是移动端也能用的 SSH 客户端。移动端有个 SSH 客户端很少认真面对的问题:连接会静默死掉,而你察觉不到。
iOS 把 App 切后台后,系统几十秒到几分钟内会挂起进程、断掉 TCP。网络切换、服务器失联、设备休眠唤醒,都会让一条 SSH 连接悄无声息地断掉——注意是悄无声息,没有任何事件通知你。这篇讲怎么检测这种死连接,以及一个反直觉的坑:「命令能返回」不代表连接还活着。
旧版本的「卡死」
最早的实现里,连接断了之后症状是这样的:底层通道的输出流(一个 AsyncStream<Data>)静默结束,读取循环退出,然后……没有然后了。会话停在最后一屏画面上,你敲字没有回显,界面看起来完全正常,但其实什么都发不出去。用户的感受是「卡死了,还没法恢复」——只能关掉这个标签重连,丢掉所有上下文。
问题有两层:察觉不到断线,以及察觉到了也没法原地恢复。
两种断法,需要两种检测
断线其实分两种,得分别对付。
第一种:流自然结束。 远端发了 FIN/RST、进程正常退出——这种情况下输出流会结束。检测它很简单:读取循环 for await data in channel.output 跑完之后,判断一下是不是主动取消(用 Task.isCancelled 区分「我自己 dispose/重绑」和「真断线」),是真断线就标记会话断开。这是快路径。
第二种:TCP 静默挂起。 网络掉了、服务器失联了、设备睡了又醒——这种情况下 TCP 不会给你任何信号,输出流也不会结束。它就那么挂着。光靠「流结束」这个信号,永远检测不到这一类。得主动去探。
心跳的坑:execute 会「假装成功」
主动探活的朴素写法是:每隔几秒发一条命令,看它返不返回。返回就是活的,超时就是死的。
这个写法有个致命的坑,是实测踩出来的。
问题在于底层的 execute(执行一条命令拿结果)会吞掉通道错误并返回——非零退出它返回,甚至连接刚死、NIO 还没来得及把通道标记成 inactive、isConnected 还是 true 的那个瞬间,命令也会「快速返回」。也就是说,一条已经死了的连接,execute 照样能返回给你一个(空的、错的)结果。
如果你只看「execute 有没有返回」来判断死活,那你会把一堆死连接判成活的。连接明明断了,心跳却一路绿灯。
令牌:只信服务器真的回了包
修法是:不看命令返不返回,看服务器有没有真的回话。
发一条带唯一令牌的命令:
echo __shellby_alive__
然后只有当 stdout 里真的出现了 __shellby_alive__,才算这条连接是活的。超时、或者返回了但没有令牌,一律判死。
这个区别很微妙但很关键:execute 返回,只证明「本地这次调用走完了」;stdout 里出现令牌,才证明「命令真的到了服务器、服务器真的执行了、结果真的回来了」——一个完整的往返。前者可以在死连接上假成功,后者不行。探活探的不是「我的调用完成了没」,而是「对面还在不在」,这两件事必须用一个走完整个链路的信号来区分。
配套几个抗抖动的细节:
- 连续两次失败才判死,避免单次网络抖动误伤;
- 单次往返 8 秒超时,用 TaskGroup 让命令和计时器赛跑,谁先到用谁;
- 心跳间隔可配(默认 30 秒,也可选 15/60 或关闭),而且心跳循环每一轮都重新读设置,改值或开关立刻生效。
还有个诚实的取舍:任何探活都要发真实流量,所以开着心跳≈开着 SSH keepalive——它会保活空闲连接(顺便防住 NAT 或服务器的空闲超时掐断),但它救不活一条已经死的连接,只能尽快发现死连接。关掉心跳则只剩「流结束」检测,静默挂起就察觉不到。这个权衡直接摆给用户在设置里选。
原地重连:换通道,不换会话
检测到死连接只是一半,另一半是恢复——而且要保住上下文。
关键设计是:重连时复用同一个会话对象和同一个终端渲染面,只把底层通道换掉。滚动历史、屏幕缓冲,全都在渲染面里存着,换通道不清屏,所以重连之后你之前的输出、你敲了一半的东西,都还在。
流程是:标记「重连中」→ 拆掉旧 SSH 连接 → 重新认证、开一个新的 PTY 通道 → 让会话「重绑」到新通道 → 标记「已连接」→ 对新连接重启心跳。重绑只是重启读取循环,不碰屏幕缓冲。
这里有个解耦的手法值得一提:负责会话管理的对象不持有数据层(不知道怎么重新建立一个连接)。重连能力是通过一个闭包注入进去的,由上层提供。首次连接和重连共用同一条建连路径。会话管理器只管「什么时候该重连」,「怎么重连」是别人的事——职责边界很干净。
让「断了」和「在恢复」都看得见
最后是把状态暴露给用户,因为旧版本最大的问题就是「卡死了还不告诉我」。
- 断线/重连时盖一层半透明覆盖层:图标 + 「连接已断开 / 正在重连…」+ 原因 + 一个「重新连接」按钮;
- 标签上的状态点三色:连着是绿、重连中是橙、断了是红;
- 三个手动重连入口(标签右键、分屏 pane 标题、iPhone 菜单),万一自动检测失灵,用户永远有个手动逃生口。
小结
做移动端 SSH 的可靠性,几条带走的经验:
- 静默挂起是移动端的常态,光靠「流结束」检测不到,必须主动探活;
- 探活最大的坑是「命令能返回≠连接还活着」——底层调用会在死连接上假成功。要用一个走完整往返、只有对面真回话才成立的信号(带唯一令牌的 echo),而不是「调用有没有返回」;
- 恢复要保上下文:复用会话和渲染面、只换底层通道,重连后不丢历史;
- 状态必须可见:断线、重连、失败都要让用户看得见,并永远留一个手动重连的口子。
一句话:判断一个远端还在不在,别问「我的请求完成了吗」,要问「你回话了吗」。
留言