跳到正文
志异-凡
返回

一次 yazi 复制失败,挖出 tmux 3.7 的一个真 bug:OSC 52 排障全记录

编辑本页

我的工作流:macOS 上用 Ghostty,SSH 连到一台远程 Linux 服务器(Kylin V11, aarch64),服务器上常驻 tmux,tmux 里跑 yazi 管理文件。

在 yazi 里选中文件按 cc,本该把文件路径复制到 Mac 本地剪贴板。但实际表现是:按了,没报错,没提示,Cmd+V 粘贴出来的还是旧内容——静默失败。

剪贴板数据要从最里层一路传到最外层:

yazi  →  tmux  →  SSH  →  Ghostty  →  macOS 剪贴板

任何一环吞掉数据,现象都一样。这篇记录完整的定位过程——最后不但修好了自己的问题,还顺手挖出 tmux 3.7 的一个真 bug,打了补丁提交上游。

背景:终端里怎么”复制到本地剪贴板”

远程服务器上没有显示服务器,xclip/wl-copy 无从谈起。终端程序想写用户本地机器的剪贴板,只有一条路:OSC 52 转义序列:

ESC ] 52 ; c ; <base64内容> ESC \

终端模拟器收到后解码写入系统剪贴板,SSH 只是透明搬运字节。

但中间隔着 tmux 时有两条路,各有一个开关:

  1. 裸发 OSC 52:由 tmux 拦截后转发给外层终端——受 set-clipboard 控制
  2. passthrough 透传:包在 ESC P tmux; ... ESC \ 里让 tmux 原样放行——受 allow-passthrough 控制

第一章:tmux 3.3a 上的三重坑

服务器上的 tmux 是 3.3a。一番排查,发现这版本上叠了三层问题。

坑 1:allow-passthrough 默认关闭

tmux 3.3 引入了这个选项,默认 off,透传序列直接被吞。开启并做个注入测试——从外部往窗格的 pty 从属端写入构造好的序列,模拟程序输出:

printf "\033Ptmux;\033\033]52;c;$(echo -n test | base64)\007\033\\" > /dev/pts/N

本地剪贴板立刻有了内容。说明 tmux → Ghostty → macOS 传输链路没问题,问题在 yazi 一侧。

Tip

往 /dev/pts/X(从属端)写数据,会从 pty 主端被 tmux 当作”程序输出”读到,是测试终端转义行为的利器。

坑 2:yazi 用了新版本 tmux 的语法

YAZI_LOG=debug yazi 的日志里躺着答案:

ERROR yazi_emulator::mux: Failed to configure tmux passthrough and input buffer:
stderr: bad value: all

yazi 启动时执行 tmux set -p allow-passthrough all,而 all 是 tmux 3.4 才加的取值;它还要设置 input-buffer-size,那是 3.6 才加的选项。3.3a 上一个都不认识。

坑 3:set-clipboard external 不转发窗格内的 OSC 52

用 script 命令给 yazi 录下原始输出字节流,od -c 查看,发现 yazi 按 cc 时发的是裸 OSC 52(内容 base64 解码后正是悬停文件的路径,没毛病):

script -q -f -c yazi /tmp/yazi.raw   # 按 cc,退出,分析 /tmp/yazi.raw

裸序列依赖 tmux 拦截转发,而实测 3.3a 的 set-clipboard external 不会转发窗格内应用的 OSC 52,改成 on 才通:

# ~/.tmux.conf
set -g set-clipboard on
set -g allow-passthrough on

配上一个把 all 改写成 on 的 tmux shim 脚本,3.3a 上终于能复制了。但这终究是打补丁——决定升级到最新 tmux 3.7c,源码编译安装,卸载 3.3a,重启 server。

第二章:升级到 3.7c,竟然还是不行

本以为故事到此结束。结果在新版上按 cc——还是复制不出来。

strace 拿到铁证

这次换个思路,不看剪贴板(避免和手工操作打架),直接 strace tmux server 进程,观察它到底往客户端写了什么:

strace -f -p <tmux-server-pid> -e trace=write,writev,sendmsg -s 400 -o /tmp/tm.strace

对照实验:

注入序列tmux 是否 writev 给客户端
裸 OSC 52✅ writev(8, "\33]52;c;...\7", ...)
2026l + OSC 52 + 2026h(yazi 的真实模式)❌ 什么都没有
2026l,等 2 秒,再 OSC 52✅ 又通了

铁证如山:紧跟在 2026l 后面的 OSC 52 被 tmux 3.7c 吃掉了。

什么是 2026 同步输出

?2026 是”同步输出”协议(BSU/ESU):TUI 程序把一帧的绘制包在 ESC[?2026h … ESC[?2026l 之间,终端收到结束标记才原子性地整帧渲染,避免慢链路上的画面撕裂。Ghostty、kitty、WezTerm 都支持,yazi 每帧都发。

tmux 直到 3.7 才支持这个协议(issue #4744)——这就是 3.6 没事、3.7 中招的原因。

源码里的真凶

翻 3.7c 源码。input.c 里 2026l 的处理:

case 2026:
    screen_write_stop_sync(ictx->wp);
    if (ictx->wp != NULL)
        ictx->wp->flags |= PANE_REDRAW;   // ← 给窗格打上"待重绘"标记
    break;

紧接着同一批数据里的 OSC 52 走到 screen_write_set_client_cb:

if (wp->flags & (PANE_REDRAW|PANE_DROP))
    return (-1);   // ← 待重绘的窗格,命令直接丢弃

剪贴板写入没有任何屏幕副作用,却被重绘守卫误杀。延迟 2 秒的测试之所以成功,是因为重绘发生后标记被清掉了。

一行补丁

screen_write_setselection 里给 tty 上下文加上 TTY_CTX_INVISIBLE_PANES,绕过重绘检查(顺带还支持了后台窗格的剪贴板写入):

--- a/screen-write.c
+++ b/screen-write.c
@@ -2865,6 +2865,7 @@ screen_write_setselection(struct screen_write_ctx *ctx, const char *clip,
 	struct tty_ctx	ttyctx;

 	screen_write_initctx(ctx, &ttyctx, 0, 0);
+	ttyctx.flags |= TTY_CTX_INVISIBLE_PANES;
 	ttyctx.sel.clip = clip;
 	ttyctx.sel.data = str;
 	ttyctx.sel.size = len;

重新编译、安装、kill-server 重启,端到端验证:yazi 里 cc,Mac 剪贴板里出现了完整路径。✅

版本兼容性速查表

tmuxallow-passthrough allinput-buffer-size2026 同步输出yazi cc
3.3a❌❌不支持❌
3.4 / 3.5✅❌不支持⚠️ yazi 启动报错
3.6 / 3.6b✅✅不支持✅ 最佳原版
3.7+✅✅支持(有 bug)❌ 需补丁

方法论:这次排障值得记住的几件事

  1. 分段验证,别端到端瞎猜。构造已知内容从中间环节注入,立刻能把”传输层故障”和”应用层故障”切开。
  2. 往 pty 从属端写入 = 模拟程序输出,测终端转义行为的神器。
  3. script 可以给任何 TUI 录下原始输出字节流,od -c 一看便知程序到底发了什么,不靠猜。
  4. strace 服务端进程,直接观察”它到底转发没转发”,比看客户端表现更可靠——尤其是剪贴板这种会被手工操作干扰的状态。
  5. TUI 的 debug 日志(YAZI_LOG=debug)往往直接写着答案。
  6. 升级不一定解决问题,还可能引入新问题。3.3a 上是”版本太老”,3.7c 上是”版本太新”——最后落在版本矩阵里找准位置,配合源码阅读定位根因。
  7. 终端工具链的兼容性比想象中脆弱:tmux 的选项命名空间持续演进,新 TUI 程序毫不客气地用新特性,两边一错位就是静默失败。

最后把最小复现和补丁整理成了 issue 提交给 tmux 上游。如果你在 tmux 3.7+ 上也遇到”OSC 52 复制偶尔/总是失灵”,现在知道为什么了。


编辑本页
分享这篇文章:

💬 评论


上一篇
tmux 使用指南:让 OpenCode 不怕关窗断线