本文是对The bottom emoji breaks rust-analyzer 的整理与翻译,有适当删减。
有些 bug 只是有趣,而另一些则堪称"美味"。今天要聊的,就是后者。
这篇文章要追查的问题,表面上看起来很荒唐:在 Emacs 里用 rust-analyzer 写 Rust 代码,只要在注释里输入一个表情符号,整个语言服务器就会崩溃。从复现问题、到深挖编码原理、再到定位 LSP 协议层的根因,这是一次相当精彩的 debug 之旅。
内容结构概览
- 环境搭建与问题复现(上) --- 从零配置 Emacs + rust-analyzer
- rust-analyzer 的发行方式 --- rustup、代理二进制与 lsp-mode 的糟糕回退逻辑
- 问题复现(下) --- 输入表情符号,服务器崩溃
- 深入 UTF-8 与 UTF-16 --- 用 Rust 代码亲手验证编码差异
- 语言服务器协议(LSP) --- 协议结构,以及用 Zig 写一个代理来抓包
- 根因分析 --- UTF-16 偏移量 vs UTF-8 偏移量的致命错位
- rust-analyzer 的正确处理方式 --- 应该 panic 还是优雅降级?
一、环境搭建与问题复现(上)
作者 Amos 从未用过 Emacs,为了复现这个 bug,他从头开始配置。在 Ubuntu 22.10 上安装 Emacs:
bash
$ sudo apt install emacs-nox
接着创建一个新的 Rust 项目:
bash
$ cargo new bottom
$ cd bottom/
$ emacs
进入 Emacs 后,打开 src/main.rs,发现默认情况下什么都没有------没有语法高亮,没有代码智能提示。为了启用这些功能,他在 ~/.emacs 中加入了以下配置:
elisp
;; in `~/.emacs`
(require 'package)
(add-to-list 'package-archives '("melpa" . "https://melpa.org/packages/"))
(add-to-list 'package-archives '("gnu" . "https://elpa.gnu.org/packages/"))
(package-refresh-contents)
(package-install 'use-package)
(require 'use-package-ensure)
(setq use-package-always-ensure t)
(use-package rustic)
(use-package lsp-mode
:ensure
:commands lsp
:custom
(lsp-rust-analyzer-cargo-watch-command "clippy")
(lsp-eldoc-render-all t)
(lsp-idle-delay 0.6)
(lsp-rust-analyzer-server-display-inlay-hints t)
:config
(add-hook 'lsp-mode-hook 'lsp-ui-mode))
(use-package lsp-ui
:ensure
:commands lsp-ui-mode
:custom
(lsp-ui-peek-always-show t)
(lsp-ui-sideline-enable nil)
(lsp-ui-doc-enable t))
重启 Emacs 后插件自动安装,代码高亮和行内文档都正常工作了------前提是你系统里已经有一个 rust-analyzer 二进制。
但当 Amos 把旧版本的 rust-analyzer 删掉之后:
bash
$ rm $(which rust-analyzer)
$ emacs src/main.rs
# Server rls:83343/starting exited ...
# Do you want to restart it? (y or n)
服务器直接启动失败了。
二、rust-analyzer 的发行方式,以及 lsp-mode 的糟糕回退
Rust 的工具链通常通过 rustup 管理。rust-analyzer 在成为官方项目之后,也开始以 rustup 组件的方式发行。
安装它:
bash
$ rustup component add rust-analyzer
但安装之后,~/.cargo/bin/ 下并不会出现 rust-analyzer 的代理二进制。它的实际路径需要用 rustup which 来查询:
bash
$ rustup which rust-analyzer
/home/amos/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin/rust-analyzer
这就引发了一个问题:lsp-mode 按 PATH 查找 rust-analyzer,找不到,于是它开始找备选方案------RLS。
查看 *lsp-log* 缓冲区,会看到这样的日志:
vbscript
Command "rls" is present on the path.
Command "rust-analyzer" is not present on the path.
Found the following clients for .../src/main.rs: (server-id rls, priority -1)
The following clients were selected based on priority: (server-id rls, priority -1)
问题在于:RLS 早已被废弃(deprecated),它的代理二进制确实存在于 ~/.cargo/bin/rls,但执行后会直接报错:
bash
$ rls
error: 'rls' is not installed for the toolchain 'stable-x86_64-unknown-linux-gnu'
lsp-mode 的检测逻辑是:只要二进制文件存在且可以被找到,就认为它"可用",于是回退到 RLS,而 rust-analyzer 的"自动下载"逻辑从来没有被触发过。
这个回退机制相当糟糕。作者注意到,自己认识的几个用 Emacs 写 Rust 的人,最终都选择了从源码编译 rust-analyzer------这会给他们一个非常新鲜的版本,但之后大概率就忘记更新了,这比直接用 rustup 还要糟糕。
临时解决方案 :用暴力手段,在 ~/.cargo/bin/rust-analyzer 创建一个 shell 脚本:
bash
#!/bin/bash
$(rustup which rust-analyzer) "$@"
bash
$ chmod +x ~/.cargo/bin/rust-analyzer
$ hash -r
$ rust-analyzer --version
rust-analyzer 1.67.1 (d5a82bb 2023-02-07)
好,"犯罪"完成。
三、问题复现(下):输入表情符号,服务器崩溃
配置好 Emacs 和 rust-analyzer 之后,来到正题。在代码里插入一个表情符号:
rust
fn main() {
// 🥺
println!("Hello, world!");
}
就在输入这个表情的瞬间,底栏弹出提示:LSP 服务器崩溃,是否重启?
查看 *rust-analyzer::stderr* 缓冲区:
ruby
Panic context:
>
version: 1.67.1 (d5a82bb 2023-02-07)
notification: textDocument/didChange
thread 'LspServer' panicked at 'assertion failed: self.is_char_boundary(n)',
/rustc/.../library/alloc/src/string.rs:1819:29
stack backtrace:
...
3: <alloc::string::String>::replace_range::<core::ops::range::Range<usize>>
4: rust_analyzer::lsp_utils::apply_document_changes::...
...
崩溃发生在 String::replace_range,断言 self.is_char_boundary(n) 失败。
如果你对这个 panic 有所直觉,作者提醒你:它比你想象的要复杂得多。
四、深入 UTF-8 与 UTF-16
要理解这个 bug,必须先搞清楚字符编码。
Rust 字符串天然是 UTF-8 。String 和 &str 都是如此,这是 Rust 的核心设计原则。
我们可以直接打印字节表示来验证:
rust
fn main() {
println!("{:02x?}", "abc".as_bytes());
}
// 输出:[61, 62, 63]
UTF-16 则是另一套编码方案。Rust 里没有内置的 UTF-16 字符串类型,但可以用 widestring crate 来演示:
rust
fn main() {
let s = widestring::u16str!("abc");
println!("{:04x?}", s.as_slice());
}
// 输出:[0061, 0062, 0063]
对于 ASCII 字符来说,UTF-8 和 UTF-16 的差别只是"有没有多余的零字节",但一旦进入非 ASCII 的领域,就截然不同了。
以几个典型字符为例:
| 字符 | UTF-8 字节 | UTF-16 码元 |
|---|---|---|
| é (U+00E9) | [c3, a9] (2 字节) |
[e9, 00] (1 码元) |
| ぁ (U+3041) | [e3, 81, 81] (3 字节) |
[3041] (1 码元) |
| 𠀀 (U+20000) | [f0, a0, 80, 80] (4 字节) |
[d840, dc00] (2 码元) |
| 🥺 (U+1F97A) | [f0, 9f, a5, ba] (4 字节) |
[d83e, dd7a] (2 码元) |
这里有一个关键现象:像 U+1F97A("求求你了脸"表情)这样位于基本多文种平面(BMP,U+0000 到 U+FFFF)之外的字符,在 UTF-16 中需要用一个"代理对"(surrogate pair)来表示------由两个 16 位码元组成,合计 4 字节。而在 UTF-8 中,它同样需要 4 个字节。
为什么 Java 和 JavaScript 使用 UTF-16?
历史原因。当年人们认为 65536 个字符"绝对够用了",于是发明了 UCS-2(每个字符固定 2 字节)。后来发现不够用(光是汉字就远超这个数字),又不得不引入代理对机制,UCS-2 就演变成了 UTF-16。Java 和 JavaScript 诞生于这个历史时期,字符串内部表示自然就选了 UTF-16。
UTF-16 代理对的计算方式:
对于码点 U+20000,减去 0x10000 得到 0x10000:
rust
0b00010000000000000000
<--------><-------->
hi lo
高 10 位加上 0xD800 得到高代理(0xD840),低 10 位加上 0xDC00 得到低代理(0xDC00)。
反推 🥺(UTF-16 代理对 [d83e, dd7a]):
scss
(0xd83e - 0xD800) = 0x3e → 高 10 位
(0xdd7a - 0xDC00) = 0x17a → 低 10 位
(0x3e << 10) + 0x17a + 0x10000 = 0x1F97A → U+1F97A
Rust 的 replace_range 为什么会 panic?
Rust 的 String::replace_range 是安全 API,它在内部会检查传入的字节范围是否落在 UTF-8 字符边界上。如果你传入一个"劈开"多字节字符的偏移量,它会直接 panic------而不是像 unsafe 代码那样产生 undefined behavior。
演示:
rust
fn main() {
let mut s = String::from("🥺");
s.replace_range(3..4, "#"); // 🥺 是 4 字节,偏移 3 正好在字符内部
}
arduino
thread 'main' panicked at 'assertion failed: self.is_char_boundary(n)',
.../library/alloc/src/string.rs:1811:29
这正是 rust-analyzer 崩溃时的堆栈!
五、语言服务器协议(LSP)与协议抓包
为了搞清楚 lsp-mode 到底发送了什么,作者用 Zig 写了一个 LSP 代理程序,把所有编辑器和服务器之间的消息都转储到 /tmp 目录下,逐条分析。
LSP 采用类似 HTTP/1.1 的消息格式------先是 Content-Length 头,再是 JSON 正文:
css
Content-Length: 4467
{"jsonrpc":"2.0","method":"initialize","params":{...}}
作者用 Zig 实现了一个完整的消息代理,核心逻辑是两个线程分别转发"编辑器到服务器"和"服务器到编辑器"的消息,同时将每条消息写入文件。
用这个代理替换掉 rust-analyzer 后:
bash
$ cp zig-out/bin/lsp-proxy ~/.cargo/bin/rust-analyzer
重新打开 Emacs,输入那个表情符号,然后查看捕获到的消息。
关键的消息是 textDocument/didChange,即编辑器通知服务器"文件内容发生了变化"。消息的大致结构是这样的:
json
{
"jsonrpc": "2.0",
"method": "textDocument/didChange",
"params": {
"textDocument": { ... },
"contentChanges": [
{
"range": {
"start": { "line": 1, "character": 5 },
"end": { "line": 1, "character": 5 }
},
"text": "🥺"
}
]
}
}
这里的 character 字段,就是问题的关键所在。
六、根因:UTF-16 偏移量 vs UTF-8 偏移量
LSP 规范中对 character 字段的定义是这样的(原文链接):
Character offset on a line in a document (zero-based). The meaning of this offset is determined by the negotiated
positionEncoding.
在 LSP 3.17 之前,positionEncoding 默认且唯一的选项是 UTF-16 码元偏移量 。也就是说,character: 5 意味着"从行首数第 5 个 UTF-16 码元之后的位置"。
这是为什么?因为 LSP 最初是微软为 Visual Studio Code 设计的,而 VS Code 内部用 JavaScript 处理字符串,JavaScript 字符串天然是 UTF-16 的。从编辑器传递给服务器的偏移量,自然也就是 UTF-16 码元偏移量。
问题来了:Emacs 和 lsp-mode 的内部字符串编码不是 UTF-8,也不是 UTF-16,而是一套 Emacs 自己定义的编码(基本上可以当作"每个字符对应一个逻辑单元"来理解)。当 lsp-mode 把 Emacs 内部的位置转换成 LSP 的 character 偏移量时,它是按 UTF-16 码元 来计数的,这完全符合 LSP 规范。
但 rust-analyzer 这边呢?在出问题的版本中,它拿到这个 character 偏移量之后,直接把它当作 UTF-8 字节偏移量 来处理,然后调用 String::replace_range------而在 UTF-8 中,一个 BMP 以外的字符占 4 个字节,一个 UTF-16 代理对的"2"正好落在字符内部,于是 is_char_boundary 断言失败,程序 panic。
总结一下这个问题的完整逻辑:
- 在 Emacs 里,光标位于
🥺之后,内部偏移为5(行首//+ 空格 + emoji,Emacs 按字符计数) - lsp-mode 将位置转换为 UTF-16 码元偏移,
🥺占 2 个 UTF-16 码元,所以发送character: 5(假设前面 3 个字符各占 1 个码元) - rust-analyzer 收到
character: 5,错误地将其当作 UTF-8 字节偏移量 - 在 UTF-8 中,
🥺占 4 个字节,字节偏移5落在这 4 个字节的内部 String::replace_range检查字符边界,断言失败,panic
七、rust-analyzer 的正确处理方式
那么 rust-analyzer 应该怎么做?
首先,它应该正确地将 UTF-16 码元偏移量转换为 UTF-8 字节偏移量,而不是直接使用。这是 LSP 客户端-服务端之间的标准约定,服务端有责任处理好这个转换。
其次,即便收到了"格式错误"的偏移量,也不应该 panic------panic 会直接终止进程,让用户体验极差。更好的做法是记录一个错误日志,忽略这个有问题的消息,然后继续运行。毕竟,一个字符的位置出错,最多让这一次编辑同步失败,不应该导致整个语言服务器崩溃。
从 LSP 3.17 开始,协议加入了 positionEncoding 协商机制,服务端可以在初始化时声明自己支持的位置编码(UTF-8、UTF-16 或 UTF-32),客户端按照服务端的偏好来发送偏移量。这从根本上解决了这类歧义问题。
这个 bug 的修复,实际上分为两个层面:
短期修复:rust-analyzer 在处理文档变更时,正确地进行 UTF-16 到 UTF-8 的偏移量转换,并在遇到非法偏移时不要 panic,而是记录错误并跳过。
长期修复:在初始化握手阶段,rust-analyzer 声明自己偏好 UTF-8 位置编码,现代编辑器(包括 lsp-mode 的新版本)会相应地切换到 UTF-8 偏移量,彻底避免这类转换问题。
总结
这个 bug 的"美味"之处,在于它串联起了多个层面的知识:
工具链层面:rustup 的安装方式没有创建代理二进制,lsp-mode 在检测到废弃的 RLS 代理后错误地回退,导致用户根本没有机会触发正确的 rust-analyzer 启动路径。这个配置问题掩盖了真正的编码 bug,也让整个 Emacs + Rust 的初始体验比它本该有的要差很多。
编码层面 :UTF-8 和 UTF-16 对于 BMP 以外的字符(包括大部分表情符号)有着截然不同的字节数和码元数。🥺 在 UTF-8 里是 4 字节,在 UTF-16 里是 2 个码元(也是 4 字节,但"计数单位"不同)。这个差异在大多数时候不可见,直到有人把"UTF-16 码元数"当成"UTF-8 字节偏移"来使用。
协议层面:LSP 规范长期以来默认使用 UTF-16 码元偏移量,这是历史包袱------VSCode 是 JavaScript 应用,JavaScript 字符串是 UTF-16 的。这个设计决策渗透到了整个 LSP 生态,任何服务端在处理位置信息时都必须小心对待。
语言设计层面 :Rust 的 replace_range 用 panic 来阻止违反字符串 UTF-8 不变量的操作,而不是产生未定义行为,这是正确的。但 panic 在长时运行的服务端进程里不是一个合适的错误处理策略------服务端应该处理好外部输入的校验,而不是依赖 panic 来"保护"自己。
一个表情符号,揭开了工具链配置、字符编码、通信协议、错误处理四个层次的问题,这正是为什么这类 bug 值得被仔细记录下来。