终端粘贴原理:bracketed paste、4095 字节行限制与 UTF-8 分片

「SSH 终端是怎样做成的」系列第 3 篇。代码摘自 MistTerm v1.2.26(MistLab 出品的开源 SSH 终端客户端,AGPL 协议),只做删减。

要点

bracketed paste 是 xterm 的一个模式(DEC 私有模式 2004)。程序开启后,终端会用 ESC[200~ … ESC[201~ 包住每次粘贴,粘贴里的换行就作为文字插入,不再等同于回车。readline 从 8.1 起默认开启。另外,Linux 的 n_tty 在行模式下把一行限制在 4095 字节加换行符。大段粘贴应分片发送,且分片不能切断 UTF-8 字符。

对终端来说,粘贴就是非常快的打字。剪贴板里的文本原样写进 PTY,和用户亲手敲进去的没有区别,字节流里也没有任何东西能区分「键入」和「粘贴」。由此引出三个互不相关的问题:

问题一:粘贴的换行等于回车,bracketed paste 怎么解决

不借助任何协议往 bash 里粘三行,每行的换行一到,bash 就执行这一行。粘多行代码片段时这很不方便。如果文本是从网页上复制的,还可能带着你根本没看到的内容,包括换行,这就危险了。

解决办法是 bracketed paste,一个写在 ctlseqs 里的 xterm 扩展。想要它的程序开启 DEC 私有模式 2004(CSI ? 2004 h),此后终端会把每次粘贴包起来:

ESC [ 2 0 0 ~   <粘贴的文本>   ESC [ 2 0 1 ~
Bracketed paste:程序请求 2004 模式,终端包裹粘贴内容
Bracketed paste:程序请求 2004 模式,终端包裹粘贴内容

程序看到起始标记后,会把直到结束标记之间的所有内容当作字面文本:

bash 的提示符背后是 GNU Readline。从 readline 8.1 开始,bracketed paste 默认开启。NEWS 里写的是「Bracketed paste mode is enabled by default」,源码里的开关如下:

/* readline-8.1, rlprivate.h */
#define BRACKETED_PASTE_DEFAULT 1
/* readline-8.1, readline.c */
int _rl_enable_bracketed_paste = BRACKETED_PASTE_DEFAULT;

readline 8.0 里同一个变量初始化为 0。bash 5.1 自带 readline 8.1,所以在当前主流发行版上,交互式 bash 会主动请求 2004 模式,除非用户在 .inputrc 里写了 enable-bracketed-paste off。另外,对它认为不合适的终端,例如 TERM=dumb,readline 不会开启这个模式,见 terminal.c。

终端这一侧的约定不多,但必须做准。MistTerm 检查远端程序设置的模式位(第 1 篇),统一换行符,然后包裹载荷:

// src/ui/terminal_paste.rs
/// 换行统一成 `\n`(保持既有行为),开启 bracketed paste 时包上 `ESC[200~ … ESC[201~`,
/// 并剔除正文里伪造的起止标记(防止粘贴内容提前“结束粘贴”后被当命令执行)。
pub fn build_paste_payload(text: &str, bracketed: bool) -> Vec<u8> {
    let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
    if !bracketed {
        return normalized.into_bytes();
    }
    let body = normalized.replace("\x1b[200~", "").replace("\x1b[201~", "");
    let mut out = Vec::with_capacity(body.len() + BP_START.len() + BP_END.len());
    out.extend_from_slice(BP_START);
    out.extend_from_slice(body.as_bytes());
    out.extend_from_slice(BP_END);
    out
}

剔除标记这一行和安全直接相关。剪贴板里如果有一个字面的 ESC[201~,不设防的终端会原样转发,程序以为粘贴已经结束,后面的内容就被当成键入,连同换行一起执行。单元测试把这个行为固定了下来:

let p = build_paste_payload("ls\x1b[201~rm -rf x\n", true);
assert_eq!(p, b"\x1b[200~lsrm -rf x\n\x1b[201~");

bracketed paste 只对开启了它的程序有效。普通的 cat、用 read 读输入的脚本、不支持这个模式的 REPL,粘贴进去的换行仍然等于回车。这是程序自身的属性,终端改变不了。

问题二:粘贴长行为什么会在 4095 字节处被截断

有一类程序让 TTY 保持在行模式(POSIX termios 的 ICANON),自己不做行编辑,比如 cat、shell 内建的 read,以及不带 readline 的 Python input()。这时由内核行规程缓冲输入,直到收到换行,同时负责退格、Ctrl-U 这类编辑。这个缓冲是有限的。

Linux 上的缓冲大小定义在 drivers/tty/n_tty.c:

#define N_TTY_BUF_SIZE 4096

缓冲写满的情况由 n_tty_receive_buf_common 处理。行模式下,如果缓冲已满且其中没有完整的一行,内核会反复复用最后一个位置,保证结尾的换行还能被接收,其余多出来的字符则被丢弃。实际效果是:行模式下一行最多保留 4095 字节,外加一个换行。超出部分会被静默丢掉,读取方完全不知情。

Linux 行模式输入缓冲
Linux 行模式输入缓冲

有两个细节容易弄错:

终端绕不过这个限制,它位于服务器内核里,在 sshd 之下。终端能做的是提醒。SSH 没有让客户端查询远端 termios 状态的办法,MistTerm 只能用手头最好的信号来判断:远端程序没有开启 bracketed paste,而剪贴板里又有超过 4095 字节的行,就在发送前给出提示:

let bracketed = self.terminal.is_bracketed_paste();
// 远端在行模式(cat/read 等)时,单行 >4095 字节会被 tty 内核截断,终端无法绕过,提示用户。
if !bracketed && exceeds_canon_line_limit(&normalized) {
    self.pending_toast_error = Some(/* 建议改用 SFTP 上传或在 vim 中粘贴 */);
}

这是一个启发式判断。处于原始模式、但没开 bracketed paste 的程序会触发误报。不管提示与否,文本都原样发送。开启 bracketed paste 的交互式 shell 和编辑器把 TTY 设成非行模式,自己读字节,不受这个上限影响。

问题三:分片不切断字符

几 MB 的粘贴不能作为一条消息交给会话线程(第 2 篇)。MistTerm 把它放进队列,每片最多 PASTE_CHUNK = 4096 字节。在任意偏移处切字节缓冲,可能把一个 UTF-8 序列切开:一个汉字三个字节,一个 emoji 四个字节。大多数接收方能正确重组,但「把每次写入当作一段完整文本」的处理方式也不少见,不制造残片更稳妥。UTF-8 的边界很好找,续字节总是形如 10xxxxxx:

回退到 UTF-8 字符边界
回退到 UTF-8 字符边界
/// 取下一片长度:不超过 `max`,且不把 UTF-8 多字节字符切成两半(除非单字符已超过 max)。
pub fn next_chunk_len(buf: &[u8], max: usize) -> usize {
    if buf.len() <= max {
        return buf.len();
    }
    let mut n = max;
    // UTF-8 续字节形如 10xxxxxx;退到字符起点。
    while n > 0 && (buf[n] & 0xC0) == 0x80 {
        n -= 1;
    }
    if n == 0 { max } else { n }
}

注意检查的是 buf[n],也就是下一片的第一个字节。它如果是续字节,说明切点落在字符中间,循环就退回到这个字符的首字节。

发送循环由 UI 的帧时钟驱动,而不是一个阻塞的循环。一片数据只有被会话队列接受之后,才从待发缓冲里移除:

const GAP: Duration = Duration::from_millis(5);
const RETRY_GAP: Duration = Duration::from_millis(20);
const MAX_RETRIES: u32 = 500; // ≈10 秒无进展才放弃

如果最终放弃,会告诉用户还有多少字节没发出去。终端自己从不悄悄截断粘贴内容。

小结

常见问题

bracketed paste 是怎么工作的?

程序用 CSI ? 2004 h 开启 DEC 私有模式 2004,终端随后用 ESC[200~ 和 ESC[201~ 包住每次粘贴,程序把两者之间的内容当作普通文字。

bash 默认开启 bracketed paste 吗?

readline 从 8.1 起默认开启,bash 5.1 自带 readline 8.1。可以在 .inputrc 里设置 enable-bracketed-paste off 关闭。

终端为什么要从粘贴内容里过滤 ESC[201~?

如果剪贴板里带着结束标记,后面的内容就会被当成键盘输入,其中的换行也会被执行。

Linux 上粘贴超过 4095 字节的行为什么会被截断?

行模式下,Linux 的 n_tty 行规程最多容纳 4095 字节加换行符。MAX_CANON(255)不是这个上限。终端无法绕过这一限制,只能提前提醒。

相关文章

← 第 2 篇 · 系列目录 · 第 4 篇 →