自动摘要
正在生成中……
现象
我在 Mac 上用 SSH 连着家里的 NAS,挂了三个多小时。回来时连接已经断了:
Read from remote host fn.local: Connection reset by peer
Connection to fn.local closed.
client_loop: send disconnect: Broken pipe
断连本身不稀奇,奇怪的是之后的事:只要鼠标在终端窗口上移动,命令行里就不停冒出这样的字符:
35;6;53M35;24;53M35;25;53M35;26;53M35;27;53M35;35;53M35;34;51M...
不小心按了回车,又刷出一屏报错:
zsh: command not found: 35
zsh: command not found: 43
zsh: command not found: 43M35
zsh: command not found: 44
...
想敲命令修复也很别扭,因为手一碰触控板,新的乱码就插进正在输入的命令里。
这些字符是什么
它们是终端发给程序的鼠标事件,完整形态是一段转义序列:
ESC [ < 35 ; 24 ; 53 M
这是 SGR 鼠标协议的格式 ESC [ < 按键码 ; 列 ; 行 M。三个数字的含义:
-
35:按键码。32 表示"移动",加上 3 表示"没有按任何键",合起来就是纯粹的鼠标移动。
-
24:鼠标所在的列。
-
53:鼠标所在的行。
所以数字会随着鼠标移动连续变化,一串乱码其实是鼠标在窗口里的移动轨迹。
zsh 的行编辑器不认识这段序列,开头的 ESC [ < 被吞掉,剩下的 35;24;53M 就当作普通输入显示在命令行上。
为什么会残留
终端模拟器默认不上报鼠标。vim、tmux、htop 以及各种 TUI 程序想要支持鼠标时,会在启动时向终端输出几段转义序列来开启它,常见的有:
| 序列 | 作用 |
| --- | --- |
| ESC[?1000h | 上报按下和松开 |
| ESC[?1002h | 额外上报按住拖动 |
| ESC[?1003h | 上报所有移动,不论是否按键 |
| ESC[?1006h | 使用 SGR 编码(就是上面那种格式) |
把结尾的 h 换成 l 就是关闭。程序正常退出时会自己发一遍关闭序列,把终端恢复原样。
关键在于:这个开关的状态存在本地的终端模拟器里,而不在远程机器上。 远程程序通过 SSH 把"开启"指令传过来,本地终端照做。连接被强行切断时,远程程序根本没有机会发"关闭"指令,本地终端就一直停留在上报模式,把鼠标事件发给了接手的本地 shell。
我看到的全是 35 开头的移动事件,说明远程那个程序开的是 1003(上报所有移动)加 1006。
为什么回车后一堆 command not found
; 在 shell 里是命令分隔符。35;43;43M35;44 回车后被拆成 35、43、43M35、44 四条命令依次执行,每条都找不到,于是各报一次错。
这些报错无害,没有任何东西被真正执行。
当场修复
- 把鼠标指针移出终端窗口,或者手离开触控板。
- 按
Ctrl+C 清空当前输入行。
- 只用键盘输入:
reset
reset 做两件事:把 tty 驱动恢复到正常设置(相当于 stty sane),再向终端发送完整的复位序列。后者会关闭鼠标上报、退出备用屏幕、恢复光标和颜色,并清屏。它不影响当前目录、环境变量和历史记录。
注意 clear 不行,它只清屏,不改终端模式。
如果不想清屏,可以只关鼠标上报:
printf '\e[?1000l\e[?1002l\e[?1003l\e[?1006l\e[?1015l'
实在不行就关掉标签页重开一个,新会话默认不开鼠标上报。
顺便查了断连原因
第一反应是空闲超时,想去加保活配置。打开 ~/.ssh/config 才发现早就有了:
Host *
ServerAliveInterval 10
ServerAliveCountMax 5
每 10 秒探测一次,连续 5 次无响应才放弃。而且保活失败时的报错是 Timeout, server ... not responding,我看到的却是 Connection reset by peer,意思是对端主动回了 RST。所以空闲超时可以排除,更可能是下面几种之一:
- Mac 睡眠后唤醒,NAS 那边早已丢弃了连接。
- NAS 重启,或者上面的 sshd 被重启。
- 网络切换,比如 Wi-Fi 重连、IP 变化。
具体是哪个我没有去确认。重要的是,这几种情况保活都救不了,断连没法根除。
排查时还发现配置里 fn.local 被定义了两次。SSH 对每个选项取第一次匹配到的值,后一条实际上不起作用,就顺手删了。想知道某个主机最终生效的配置,可以用:
ssh -G fn.local
根治:让 ssh 退出后自动复位
既然断连避免不了,那就让终端在 ssh 结束后自己恢复。在 ~/.zshrc 里给 ssh 包一层:
# ssh 退出后关闭鼠标上报并恢复光标
ssh(){
command ssh "$@"
local rc=$?
[[ -t 1 ]] && printf '\e[?1000l\e[?1002l\e[?1003l\e[?1006l\e[?1015l\e[?25h'
return $rc
}
几个细节:
-
command ssh 调用真正的 ssh,避免函数递归调用自己。
- 先保存退出码,最后原样返回,这样
ssh host cmd && ... 之类的写法不受影响。
-
[[ -t 1 ]] 判断标准输出是不是终端。没有这一句,x=$(ssh host cmd) 或 ssh host cmd | grep ... 捕获的结果里会混进转义字符。
- 末尾的
\e[?25h 是恢复光标显示,有些程序异常退出后光标也会消失。
这个函数只在交互式 shell 里生效。git、rsync、scp 直接调用 ssh 二进制,不会经过它。
小结
- 满屏的
35;列;行M 是鼠标移动事件,不是程序输出,也不是终端坏了。
- 根源是远程程序开了鼠标上报,SSH 异常断开导致关闭指令没发出来,而开关状态留在了本地终端。
- 当场用
reset 解决,长期用一个 ssh 包装函数自动复位。
- 保活配置管不了
Connection reset by peer 这类断连。