下午收到一个 capture-output.tar.gz,说是一个 Oh My Zsh 插件,想让我先 audit 再装。插件的卖点很酷:把 zsh 塞进一个 PTY wrapper,wrapper 在字节流上识别 OSC 标记来划分每条命令的输出边界,然后写到 POSIX 共享内存里。装完之后敲 clc 就把上一条命令的输出原样复制到剪贴板,零磁盘 I/O。
我喜欢这个设计——pbpaste 风格的东西大家都写过,但走 PTY + shm 是真能把”捕获任意命令的完整输出”做到干净的方案,因为它既不动 redirect 也不改 shell 内建,交互式程序(vim、fzf、ssh)照样能跑。
问题是这个实现有坑。我装了一遍,从 audit 到能用,一共打了五个补丁。
先审再装
解包先看目录:
tar -tzvf capture-output.tar.gz有点意思的是里面躺着一个叫 {src,bin}/ 的空目录——典型的打包时 shell brace expansion 没被展开的残留。无害,但能看出原作者打包时顺手敲了 mkdir {src,bin} 结果 shell 把它当字面量了。
逐个文件读:install.sh 用 set -euo pipefail,只往 $ZSH_CUSTOM/plugins/ 复制,可选装进 /usr/local/bin;wrapper.c 只有 forkpty + 字节流状态机 + shm_open;clc.c 只做 shm_open(O_RDONLY) + popen("pbcopy", "w")。没有网络调用,没有 eval,没有 base64 解码,没有 rm -rf /。代码质量和安全面都没问题。
装。
echo N | bash install.shecho N 是为了拒绝它问我要不要装进 /usr/local/bin——我偏好让 PATH 只认插件目录下的 bin。编译零警告(-O2 -Wall -Wextra -Wpedantic -std=c17),二进制就位。
改 .zshrc 把插件加进 plugins=(),并在 source $ZSH/oh-my-zsh.sh 之前加 exec 块:
plugins=(git capture-output)
export PATH="$ZSH/custom/plugins/capture-output/bin:$PATH"if [[ -z "$ZSH_CAPTURE_ACTIVE" ]] && [[ -x "$ZSH/custom/plugins/capture-output/bin/zsh-capture-wrapper" ]]; then exec "$ZSH/custom/plugins/capture-output/bin/zsh-capture-wrapper"fi
source $ZSH/oh-my-zsh.sh这里第一个小坑:原 README 说 “add BEFORE source oh-my-zsh.sh”,但没提 wrapper 二进制在插件目录里——插件的 PATH 导出发生在 source oh-my-zsh.sh 之后,exec 块却跑在它之前,command -v zsh-capture-wrapper 会找不到。所以我显式在 exec 之前把 bin 目录塞进 PATH,或者干脆用绝对路径 exec。
重启终端,看到新 shell 起来,敲 clc --help 能出帮助。
然后,就不对了。
补丁一:clc 读不到自己的前一条命令
我敲了一条 echo hello,接着 clc --print,期待看到 hello。
$ echo hellohello$ clc --printclc: capture buffer is empty空的。
愣了一下,开始想为什么。wrapper 的核心是一个 OSC marker 协议:zsh 的 preexec 钩子在命令执行前发 \e]7770;B\a(BEGIN),precmd 在命令完成后发 \e]7770;E\a(END)。wrapper 的字节流状态机看到 BEGIN 就调 cap_reset() 清空 buffer、置 g_capturing = true,看到 END 就 cap_finalize()。
看一眼 cap_reset():
static void cap_reset(void) { atomic_store(&g_buf->ready, 0); atomic_store(&g_buf->len, 0);}这是在 BEGIN 的时候被调的。意思是:每次新命令开始,先把上一条的输出抹掉。
然后 clc 本身也是一条命令。时间线摊开看:
T0: 敲 `echo hello` → preexec 发 BEGIN → wrapper cap_reset()T1: echo 输出 "hello\n" → wrapper 捕获进 bufferT2: echo 退出 → precmd 发 END → wrapper cap_finalize(),buffer 里躺着 "hello"T3: 敲 `clc --print` → preexec 发 BEGIN → wrapper cap_reset() ← 在这里 hello 被抹了T4: clc 二进制跑,shm_open 读 buffer → len=0 → 报 emptyT5: clc 退出 → precmd → END → finalize这个实现从架构层面就是错的。clc 想读”上一条命令的输出”,但 clc 自己的 preexec 在它读之前就把 buffer 清零了。唯一的 buffer 总是当前正在跑的命令的,不是上一条的。
修复思路直接:双缓冲。shm 里开两块区域:
cur_*:正在捕获的命令,BEGIN 时清零,一边跑一边 appendlast_*:已经完成的最后一条命令,END 时从cur_*复制过来
clc 只读 last_*。这样 clc 自己的 preexec 清掉 cur_* 完全不影响 last_*,读到的永远是真正的上一条。
typedef struct { atomic_size_t last_len; atomic_int last_ready; char last_data[BUF_CAPACITY];
atomic_size_t cur_len; char cur_data[BUF_CAPACITY];} CapBuf;
static void cap_reset(void) { atomic_store(&g_buf->cur_len, 0);}
static void cap_finalize(void) { size_t len = atomic_load(&g_buf->cur_len); atomic_store(&g_buf->last_ready, 0); memcpy(g_buf->last_data, g_buf->cur_data, len); atomic_store(&g_buf->last_len, len); atomic_store(&g_buf->last_ready, 1);}clc.c 同步改成读 last_len/last_data。SHM_NAME 从 /zsh_cap 改成 /zsh_cap2,避免和原来的旧布局撞到一起——shm 对象在 macOS 上是进程间持久的,布局一变就必须换名。
补丁二:macOS 的 shm 只允许 ftruncate 一次
我给 wrapper 加了个 --check 模式:启动前跑一遍自检,shm_open + ftruncate + mmap + munmap,能跑通就返回 0。目的是让 .zshrc 在 exec wrapper 之前先问一句”你能起来吗”,起不来就 fall through 到普通 zsh,不把终端 tab 拖着一起死。
第一次跑 --check:
ftruncate: Invalid argumentEINVAL。shm 对象明明刚创建的,为什么 ftruncate 失败?
想了一会儿反应过来:macOS 的 POSIX shared memory 有个文档没写清楚但很出名的限制——同一个 shm 对象只允许 ftruncate 一次。第一次调用会把大小定死,之后再想改都返 EINVAL。因为第一次运行时我已经创建并 truncate 过 /zsh_cap2 了,第二次就炸。
修复:先 fstat 看当前大小,够用就跳过:
static CapBuf *shm_init(void) { int fd = shm_open(SHM_NAME, O_CREAT | O_RDWR, 0600); if (fd < 0) { perror("shm_open"); return NULL; }
struct stat st; if (fstat(fd, &st) == 0 && (size_t)st.st_size < sizeof(CapBuf)) { if (ftruncate(fd, sizeof(CapBuf)) < 0) { perror("ftruncate"); close(fd); return NULL; } } ...}再跑,check OK。
补丁三:precmd 钩子顺序导致捕获进 OSC 垃圾
重启终端,跑 tailscale status 再 clc——能复制了。但粘出来,尾巴上多出一串字节:
...100.121.129.99 ubuntu-nyc1 lishuyustevenli@ linux idle; offers exit node[1m[7m%[27m[1m[0m]7;file://Mac/Users/lishuyu ]2;lishuyu@Mac ]1;~ ]7;file://Mac/Users/lishuyu \两段垃圾:前面那个 [1m[7m%[27m[1m[0m 是 zsh 的 PROMPT_EOL_MARK——bold + reverse 的 % 字符,用来提示上一条命令输出没以换行结尾;后面那串 OSC 是 OMZ termsupport 库写的窗口标题 (OSC 2)、cwd (OSC 7) 和图标名 (OSC 1)。
问题是,这两段都是在命令结束后才被写的,理论上应该在 END marker 之后才出现在字节流里。但它们在 capture 里,说明它们被 wrapper 当成了”命令输出”的一部分塞进了 buffer。这意味着 END marker 落得太晚。
翻 OMZ 的源码:lib/termsupport.zsh 通过 add-zsh-hook precmd 注册了一个函数,负责写窗口标题 OSC。我们的 __cap_precmd 也是 add-zsh-hook precmd 注册的。add-zsh-hook 默认追加——也就是说 precmd_functions 数组里是:
precmd_functions=(... termsupport_precmd __cap_precmd)termsupport 先跑,写 OSC 2/7/1;然后 __cap_precmd 才跑发 END。中间那些 OSC 字节,在 wrapper 看来全是在 g_capturing=true 的时候到达的,理所当然被捕获进 buffer。
修复:把 __cap_precmd 强制挪到数组第一个:
autoload -Uz add-zsh-hookadd-zsh-hook precmd __cap_precmdprecmd_functions=(__cap_precmd ${precmd_functions:#__cap_precmd})${precmd_functions:#__cap_precmd} 是 zsh 的 parameter expansion,意思”数组里去掉所有 __cap_precmd”。先 add 再去重再前置,保证顺序是 (__cap_precmd, termsupport_precmd, ...)。END 第一个发,后面所有 OSC 都在 g_capturing=false 时到达。
顺手把 printf 换成 zsh builtin print -rn:
__cap_precmd() { print -rn -- $'\e]7770;E\a'}为什么?因为 printf 是 stdio-based 的,向 TTY 写时默认 line-buffered——而 END marker 结尾是 \a(BEL)不是 \n,line buffer 不会在 BEL 上 flush。理论上可能把 END 字节压着不送,直到下次换行或显式 flush。zsh 的 print 内建直接调 write(2),没有 stdio buffer,发出去就是发出去。保险起见改过来。
PROMPT_EOL_MARK 的问题在同样的顺序修复里一起解决——它是 zsh 在整个 precmd 链跑完之后才打印的,既然 END 现在是链首第一个发的,EOL mark 必然在它之后到达,不会进 buffer。
补丁四:fail open, don’t fail close
测试过程中我把 wrapper 跑崩过一次(某个中间版本的 bug),因为我把 wrapper 写死在 .zshrc 的 exec 里,wrapper 一崩整个终端 tab 直接关闭,iTerm2 的 session 没了。
用户的反馈一针见血:要在 open 的时候失败,不要在 close 的时候失败——能在启动阶段检测的错误就别等运行中炸。
两件事一起做。
第一件:zshrc 里 exec 之前先跑 --check。check 失败就跳过 exec,继续走普通 zsh 流程——终端 tab 还能活,只是捕获功能没了。
if [[ -z "$ZSH_CAPTURE_ACTIVE" ]] && [[ -x "$ZSH/custom/plugins/capture-output/bin/zsh-capture-wrapper" ]]; then if "$ZSH/custom/plugins/capture-output/bin/zsh-capture-wrapper" --check 2>/dev/null; then exec "$ZSH/custom/plugins/capture-output/bin/zsh-capture-wrapper" fi # --check failed → skip wrapper, fall through to normal zshfi第二件:wrapper 运行中给 SIGSEGV / SIGBUS / SIGABRT / SIGTERM / SIGHUP 都挂上 handler,先 restore_terminal() 把 TTY 从 raw mode 还原,再 raise(sig) 走默认动作:
static void on_fatal(int sig) { restore_terminal(); signal(sig, SIG_DFL); raise(sig);}这样就算 wrapper 中途崩了,TTY 至少不会留在 raw mode 让父 shell 变成打字乱码。这是兜底,不是根治——根治靠 --check 把真正能检测的问题拦在外面。
补丁五:把原命令一起塞进 transcript
用了两条命令之后用户说:我想看到原命令是什么。clc -p 应该像一段 transcript:
$ pwd/Users/lishuyu而不是光 /Users/lishuyu。
这个需求挺合理——复制给别人看的时候,知道是哪条命令的输出是基本前提。问题是 wrapper 现有的 marker 协议是定长 9 字节(\e]7770;B\a + \e]7770;E\a),塞不下命令字符串。
改协议。定长换成 BEL 终止的变长:
BEGIN(无命令): \e]7770;B\aBEGIN(带命令): \e]7770;B;<cmd>\aEND: \e]7770;E\a状态机改写:累积到 BEL 或者超过 PENDING_MAX(4KB)就 parse,把 pending[7..n-2] 当 payload 交给 parse_marker():
static void parse_marker(const char *payload, size_t len) { if (len == 0) return; char type = payload[0];
if (type == 'B') { cap_reset(); g_capturing = true; if (len >= 2 && payload[1] == ';') { cap_append("$ ", 2); cap_append(payload + 2, len - 2); cap_append("\n", 1); } } else if (type == 'E') { g_capturing = false; cap_finalize(); }}看到 B;<cmd> 就先 reset、置 capturing,然后往 cur_data 前面塞一行 $ <cmd>\n。接下来命令本身的输出会被 append 在后面,形成一个完整的 $ 命令\n输出 结构。
plugin 侧改 __cap_preexec:
__cap_preexec() { print -rn -- $'\e]7770;B;'"$1"$'\a'}$1 是 zsh 在 preexec 钩子里传给回调的原命令行($1 是用户输入的 raw 字符串,$2 是做了历史替换的,$3 是单行化的——这里用 $1 保留原样)。print -rn 的 -r 关掉反斜杠转义,让命令里的 \ 字面量传过去;-n 不加尾部换行。
clc —print 的尾部空行
这时候又出现一个小 UI 问题:clc --print 的输出后面会跟着一个 % 符号。
这个不是捕获进 buffer 的问题。它是 zsh 的 PROMPT_SP(Prompt Spaces)机制——当一条命令输出的最后一字节不是 \n 时,zsh 会在新 prompt 之前先打印一个 bold-reverse 的 % 来提示”上面那行是部分行”。
修复方式很直白:clc --print 检查输出最后一字节,不是 \n 就补一个:
if (opt_print) { (void)write(STDOUT_FILENO, out_data, out_len); if (out_len == 0 || out_data[out_len - 1] != '\n') (void)write(STDOUT_FILENO, "\n", 1);}buffer 本身没被污染,只是输出到屏幕时确保有换行收尾。
发布
五个补丁打完,重新编译跑一遍 --check、敲几条命令 clc -p,一切正常。推到 GitHub:
gh repo create omz-capture-last-output --public --source=. --push仓库地址:github.com/StevenLi-phoenix/omz-capture-last-output。想装的话 clone 下来 ./install.sh 就行,依赖只有 macOS + Xcode CLT + Oh My Zsh。
尾声
总结一下这五个补丁背后的 pattern。
第一个(双缓冲)是架构层 bug——原作者没想到 clc 自己也是一条命令,这种”工具读写的状态同时被工具的调用者改变”的反射式 bug 在命令行工具里常见。设计时要问一句”当我的工具运行本身也是一条命令时会发生什么”。
第二个(ftruncate once)是平台怪癖——同样的代码在 Linux 上多半能跑,macOS 就炸。跨平台 C 代码要对 “这个 syscall 在 macOS 上有什么特殊限制” 保持警觉,至少对 shm_open / kqueue / fsevents 这些苹果自家东西要查一遍文档。
第三个(precmd 顺序)是抽象边界泄漏——OMZ 的 add-zsh-hook 把顺序当作实现细节,但对 wrapper 这种”必须最早/最晚执行”的需求,顺序就是语义的一部分。遇到”我的钩子和别人的钩子同时注册,谁先跑”这类问题,永远直接操作 *_functions 数组,不要依赖 add-zsh-hook 的默认行为。
第四个(fail-open)是blast radius 控制——.zshrc 里写 exec 是整个终端 tab 的生死开关,所以在 exec 前必须有最低限度的 sanity check。这个模式在启动脚本里很普适:任何会替换当前进程的操作,前面都得有个便宜的试探。
第五个(变长 marker)是协议扩展性——定长协议一开始写起来省事,但一旦需要 payload 就要整个重写。如果写的是串行协议且完全可控两端,用 BEL 终止的变长格式是零成本的选择,连前向兼容都能顺便做到(旧解析器看到新 marker 会正确报”未知类型”然后吞掉)。
一个原本三百多行的 C 项目,从 audit 读通到能用到按我要求的语义跑稳,改了五轮。代码量没涨太多,但每一轮改动都是真正意义上把设计补全。这种小工具改起来有意思的地方在于:每一个问题都能在半小时内独立复现和修复,反馈闭环短到爽。