终端里的两处乱码——vim 粘贴卡死与远程中文变问号
Shellby 复盘:一个是粘贴到 vim 后按键全部错乱,根因是并发写 SSH 通道乱了顺序;一个是远程 macOS 上中文全成转义乱码,根因是 sshd 会话默认没有 LANG。两个 bug 都在「字节到达远端的路上」出问题,但一个是顺序、一个是环境。
Shellby 是个原生 SSH 客户端,终端是它的心脏。上线后修的两个乱码 bug 表面都是「屏幕上出现了不该出现的字符」,但根因分属两层:一个是本地把字节发出去时乱了顺序,一个是远端缺了环境才把字节解错。放一起讲,正好是终端正确性的两个不同侧面。
乱码一:粘贴到 vim,之后按键全乱
现象:往 vim 里粘一段文本,粘完之后 Backspace、ESC、方向键全部失灵,按什么进去什么字面字符,终端像中了邪。
根因在并发写通道乱序。ShellChannel.write 当时是 nonisolated async,每次调用各自开一个 Task 去写——多个 Task 之间没有顺序保证。多数时候没事,但粘贴踩中了要害:SwiftTerm 处理粘贴时会分三段发送——bracketed paste 的开始标记、正文、结束标记。
Bracketed paste 是终端的一个机制:粘贴内容被 \e[200~ 和 \e[201~ 包起来,让 vim 知道「这中间是粘的,别当命令执行」。但如果三个 Task 乱序,结束标记先于开始标记到达远端,vim 就永久卡在「粘贴模式」里出不来——之后你敲的每一个 Backspace、ESC 都被当成粘贴内容字面插入。乱序把一个成对的括号拆散了。
修法是把「各自开 Task 写」换成单一常驻写循环 + FIFO 队列:所有写入进一个队列,一条常驻循环严格按入队顺序写到当前通道。顺序恢复,成对标记不再被拆散。附带解决了重连场景——写循环在重连 rebind 后自动跟随新通道,不用重建。
// 之前:每次 write 各开一个 Task,多个 Task 之间无序
// 之后:入队 → 单循环按 FIFO 顺序写当前通道
func write(_ data: [UInt8]) {
writeQueue.enqueue(data) // 常驻循环消费,顺序 = 入队顺序
}
这是并发正确性的经典教训:当写入的顺序本身携带语义(成对标记、协议帧),并发就必须串行化。开始标记和结束标记不是两次独立的写,是一个不可拆的序列。
乱码二:远程 macOS 上中文全是转义符
现象:SSH 到一台远程 macOS,vim 或 less 打开含中文的文件,中文全变成 ~T~@ 这种转义乱码。同样的操作连 Linux 却正常。
根因在远端 sshd 会话默认没有 LANG。没有 LANG,程序 setlocale 落到 C locale;C locale 下 vim/less 认为终端不支持 UTF-8,就把每个高位字节转义显示成 ~X 的形式。为什么 Linux 大多正常?因为 Linux 的登录 shell 常有 /etc/profile 之类兜底设了 LANG,掩盖了这个问题;远程 macOS 的非交互 sshd 会话没这层兜底,真空就暴露了。
修法是在建 PTY 时主动把 LANG 下发过去。PTYOptions 加了 environment 字段,默认按客户端自己的 locale 推导出一个 LANG 值,通过 SSH 的环境变量请求发给远端——两个 SSH 后端各走各的路:Citadel 走 env 请求(wantReply: false,远端没 AcceptEnv 就静默忽略,不报错),libssh2 走 channel_setenv。
但这里有个不能偷懒的地方:不能自由拼一个 locale 字符串扔过去。en_CN、zh_HK.GBK 这种「语言 + 地区」的组合很多在远端根本不存在,setlocale 遇到不存在的 locale 会静默回落到 C——等于没发,白忙一场。所以下发前先做一层映射:常见 locale 白名单校验,未命中的按规则回退到一个远端大概率存在的组合(en_CN → en_US,中文按简繁定向到 zh_CN / zh_TW)。这层映射用单测 testPosixLocaleMapping 锁死,防止以后手滑改坏。
客户端 locale ──推导──▶ 候选 LANG ──白名单校验──▶ 命中:直接下发
└─▶ 未命中:规范回退(en_CN→en_US)
两个 bug 的共性与差异
放一起看很有意思:两个都是「字节到达远端的路上」出的问题,但坏在不同环节。
- 粘贴乱码坏在传输顺序——字节没错,顺序错了,把一对协议标记拆散;
- 中文乱码坏在执行环境——字节没错、顺序也没错,是远端缺了
LANG才把正确的 UTF-8 字节解释错了。
一个是「我发的东西对不对、齐不齐」,一个是「对面拿什么规则解释我发的东西」。终端仿真的正确性就卡在这条链的每一环:本地编码、写入顺序、传输、远端 locale、远端程序的解释——任何一环错位,屏幕上都是乱码,而你得知道去链条的哪一节找。
复盘
- 顺序即语义时,并发写必须串行化。bracketed paste 的开始/结束标记是不可拆的一对,多个
Task无序写会把它拆散——用单循环 + FIFO 队列把顺序焊死; - 别依赖远端有「兜底环境」。Linux 的 profile 常替你设好
LANG,让 locale 问题隐身;换到没兜底的环境(远程 macOS 的 sshd 会话)真空立刻暴露。要正确就主动下发,别赌对面有默认; - 下发环境值要先验证目标端存在。
setlocale对不存在的 locale 静默回落 C,等于没发——自由拼接必翻车,得用白名单 + 规范回退,并用单测锁住映射; - 乱码要按链条定位。本地编码 → 写入顺序 → 传输 → 远端 locale → 远端程序解释,每一环都能产出「屏幕上的乱码」,症状相似而根因分散,先判断坏在哪一节再动手。
留言