背景

在使用 gotty 这类基于 WebSocket 的 Web Terminal 工具时,遇到一个问题:

  • 浏览器打开页面后,WebSocket 连接一切正常
  • 切换到其他浏览器标签页(Tab),停留一段时间
  • 再切回原 Tab,发现 WebSocket 已断开

gotty 日志:

2026/01/13 01:06:16 New client connected: 10.42.2.8:41704, connections: 1/0
2026/01/13 01:12:18 Connection closed by client: 10.42.2.8:41704, connections: 0/0

更有意思的是:

  • 本地调试(localhost / 内网)几乎不会复现
  • 通过公网访问时,问题稳定出现

浏览器切换 Tab 的影响

第一反应是怀疑浏览器行为。通过简单验证可以确认:

  • 浏览器会对后台页面进行 JS 执行节流 / 暂停(可以通过浏览器的开发者工具,查看WebSocket的数据交互)
  • setInterval / setTimeout 的触发频率被大幅拉长,甚至完全暂停(取决于浏览器的行为)

如果 WebSocket 的心跳依赖前端 JS 定时器,那么在后台 Tab 场景下:

心跳不再发送,连接会逐渐变成“空闲连接”

为什么本地不断,公网却会断?

我发现浏览器JS节流/暂停之后,第一时间在电脑本地也跑一个gotty,验证的结果是本地连接,即使浏览器对JS节流/暂停,也不会导致连接中断。因此初步得出一个结论:问题出现在网络环境的差异上。

本地 / 内网访问链路

browser → localhost / LAN → server

特点是:

  • 没有 NAT
  • 几乎不存在 TCP idle timeout
  • 即使连接长时间无数据,也不会被回收

因此,本地连接时即使 JS 完全暂停,连接依然“看起来还活着”。


公网访问的真实链路

browser
  ↓
路由器/防火墙 NAT
  ↓
运营商
  ↓
云防火墙 / 负载均衡
  ↓
server

这条链路上的每一层设备,几乎都会对 空闲 TCP 连接设置超时回收机制

常见的 idle timeout:

  • 家用路由器:60–300 秒
  • 云负载均衡:60–600 秒
  • 移动网络:更短

而这些回收行为通常是 silent drop

  • 不发送 FIN
  • 不发送 RST
  • 双方都不知道连接已经被中间设备“忘记

半开连接(Half-open TCP)

在后台 Tab 场景下,典型的故障链路是:

浏览器切后台
  ↓
JS 心跳暂停
  ↓
WebSocket 无任何流量
  ↓
TCP 连接空闲
  ↓
NAT / 防火墙回收连接
  ↓
连接变成半开状态

此时:

  • 浏览器认为连接仍然存在
  • 服务端认为连接仍然存在
  • 但中间网络设备已经不再转发数据

只有在下一次写数据时,问题才会暴露。


reconnect 有用吗?

gotty 支持 reconnect 机制,使用 --reconnect 参数开启。经过测试,在这个场景下无效。

我查找资料后,reconnect 是客户端重连,可能适用的场景:

  • 服务端主动close

    例如:

    • 服务端升级/重启
    • 超过最大会话时长
  • 服务端进程异常退出

    例如:

    • panic
    • OOM
    • SIGKILL
  • 客户端网络短暂中断(可感知)

    例如:

    • Wi-Fi断开又恢复
    • VPN切换

它解决的是:“断了怎么办”,而不是 “保持连接不断”


服务端 WebSocket Ping

问题的根因可以归结为一句话:

连接在后台 Tab 时没有任何网络流量,导致被中间网络设备回收

因此,正确的解决方向是:

  • 不依赖前端 JS
  • 由服务端主动制造“最小但持续”的流量

WebSocket 协议本身已经提供了标准答案:

Ping / Pong(RFC 6455)

  • 服务端发送 Ping frame
  • 浏览器网络栈自动回复 Pong
  • 该过程 不依赖 JavaScript 执行

即使页面 JS 被完全暂停,只要连接仍存在,Pong 也会被自动发送。

改造方案

在服务端 WebSocket 建立后,增加三点:

  1. 定期发送 Ping(例如每 30 秒)
  2. 设置 PongHandler,刷新 ReadDeadline
  3. 在 Pong 超时后主动关闭连接,交给 reconnect 兜底

这样可以同时达到:

  • 防止 NAT / 防火墙回收连接
  • 及时发现真正的死连接
  • 与现有 reconnect 机制完美配合

因为 gotty 项目维护者已经不活跃,不管是提ISSUE或是PR,维护者也久久不回复,因此,我在我 fork 的版本中做了修改,修改的代码在这个提交:

COMMIT:https://github.com/chenrizhi/gotty/commit/5578b76f535c9260c53c58907108a52a2994971e


参数取值的取舍

修改的代码有两个常量:serverSidePingInterval、serverSidePongWait,这两个值的取值也不能乱取的。

在不清楚真实网络 idle timeout 的前提下,采用保守策略:

  • serverSidePingInterval = 30s
  • serverSidePongWait = 90s

经验法则是:

  • ping 间隔要 小于任何可能的 NAT idle timeout
  • pongWait 至少是 pingInterval 的 2–3 倍

30s / 90s 是一个在公网、NAT、移动网络下都相对安全的折中方案。


最终效果

引入服务端 Ping 后:

  • 后台 Tab 长时间停留,连接依然保持
  • 公网环境下不再频繁触发断连
  • reconnect 只在真正异常时才会发生

总结

  • 浏览器后台 Tab 会暂停 JS 执行,这是规范行为
  • 公网环境中的 NAT / 防火墙会回收空闲 TCP 连接
  • 两者叠加,导致 WebSocket 在后台场景下“莫名其妙断开”
  • reconnect 只是补救措施,无法防止断连
  • 服务端 WebSocket Ping 才是根治方案

标签: none

添加新评论