追踪 Zsh 历史记录丢失的 Bug 🐞
原文由 Michael Stapelberg 于 发布,订阅该博客
多年来,我时不时会发现一些明明执行过的命令,在 Z shell 的历史文件(~/.zsh_history)里不翼而飞了。在本文中,我会展示自己是如何一步步追踪到这个 bug 的。先剧透一下:最终奏效的办法是给 Zsh 打上一个会让它立刻崩溃的补丁,然后分析崩溃产生的 core dump!
先说好消息
Zsh 5.9.2(2026 年 7 月 12 日发布)已经包含了对这个问题的修复——请在读完本文的排查过程之后再去查看,以免提前剧透、影响乐趣。
剧透:上游修复链接
症状
偶尔我会发现,自己明明前一天执行过的命令,在 shell 历史里却怎么也搜不到,也就是按 Ctrl+R 进行反向历史搜索时毫无结果。每次注意到这个问题时,我的历史文件里都只剩下了非常久远的记录,而近几年新增的记录全都不见了。
最初的几次,我只是从每日备份里恢复了历史文件,并没有深究。但这个问题反复出现。
我注意到 .zsh_history 并没有可见的损坏(没有不可打印字符,也没有残缺的行),而且文件的行数每次都不一样。
当时我还不清楚,这到底是 Zsh 本身的问题,还是其他某个程序的问题,又或者是多个 zsh(1) 进程共同作用导致的结果。
我的 Zsh 历史配置
我在 ~/.zshrc 中设置了以下与历史记录相关的选项:
# Load 4000 lines of history (for Ctrl+R backward search), but save O(∞)
HISTSIZE=4000
HISTFILE=~/.zsh_history
SAVEHIST=10000000
# Do not save (adjacent) duplicate entries
setopt HIST_IGNORE_DUPS
# Append history entries to `~/.zsh_history` when commands are run.
setopt INC_APPEND_HISTORY
# …but do not share history (enabled by default in NixOS’s /etc/zshrc).
unsetopt SHARE_HISTORY
实际效果是,我的各个 shell 都是独立的会话,它们都会把各自的命令持续追加到同一个共享的 ~/.zsh_history 中。历史记录被刻意设为不共享,所以当我想访问另一个 shell 写入的记录时,我会显式地执行 exec zsh。
追踪行为
当我在 2024 年 12 月在 Mastodon 上求助时(主要是希望有人已经遇到并诊断过这个问题),有人建议我使用类似 inotify 或 fsevents 这样的文件系统变更监控机制,来揪出到底是谁截断(或者说改动了?)Zsh 历史文件。
接下来几节会介绍我在 Linux 上尝试过的各种方案。
inotify
inotify(7) 是 Linux 内核中最早的文件系统变更监控子系统之一(2005 年发布)。要真正弄清楚 Zsh 是如何修改历史文件的,只监控 .zsh_history 本身是不够的:
midna ~ % inotifywait --monitor .zsh_history
Setting up watches.
Watches established.
.zsh_history OPEN
.zsh_history ACCESS
.zsh_history ACCESS
[…]
.zsh_history ACCESS
.zsh_history CLOSE_NOWRITE,CLOSE
.zsh_history ATTRIB
.zsh_history CLOSE_WRITE,CLOSE
.zsh_history DELETE_SELF
^C
文件被打开、被访问(也就是被读取),然后……被删除了?!
通过监控其所在的目录,我们就能看到全貌:
midna ~ % inotifywait --monitor ~
/home/michael/ OPEN .zsh_history
/home/michael/ ACCESS .zsh_history
/home/michael/ ACCESS .zsh_history
[…]
/home/michael/ ACCESS .zsh_history
/home/michael/ CLOSE_NOWRITE,CLOSE .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
/home/michael/ OPEN .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
/home/michael/ OPEN .zsh_history
/home/michael/ ACCESS .zsh_history
/home/michael/ CLOSE_NOWRITE,CLOSE .zsh_history
/home/michael/ CREATE .zsh_history.new
/home/michael/ OPEN .zsh_history.new
/home/michael/ ATTRIB .zsh_history.new
/home/michael/ MODIFY .zsh_history.new
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history.new
/home/michael/ MOVED_FROM .zsh_history.new
/home/michael/ MOVED_TO .zsh_history
/home/michael/ CLOSE_WRITE,CLOSE .zsh_history
原来 Zsh 会先读取旧的历史文件内容,写入一个新文件,然后把新文件重命名覆盖掉旧文件,从而删掉旧文件。现在就说得通了!
可惜的是,我们看不到触发文件系统事件的进程 ID(PID),即便是使用它的姊妹工具 fsnotifywait(1) 也看不到,尽管它使用的 fanotify(7) 本身是提供这个信息的!我检查过,内核确实会发送 PID,只是 fsnotifywait 没有把它显示出来。
fatrace
好在还有 fatrace(8),它会显示进程名和 PID。
用 fatrace(8) 来看 Zsh 重写历史记录的过程是这样的:
zsh(197994): CWO /home/michael/.zsh_history
zsh(197994): O /home/michael/.zsh_history
zsh(197994): R /home/michael/.zsh_history
zsh(197994): R /home/michael/.zsh_history
[…]
zsh(197994): R /home/michael/.zsh_history
zsh(197994): C /home/michael/.zsh_history
zsh(197994): + /home/michael
zsh(197994): O /home/michael/.zsh_history.new
zsh(197994): W /home/michael/.zsh_history.new
zsh(197994): W /home/michael/.zsh_history.new
zsh(197994): W /home/michael/.zsh_history.new
[…]
zsh(197994): W /home/michael/.zsh_history.new
zsh(197994): CW /home/michael/.zsh_history.new
zsh(197994): <> /home/michael
zsh(197994): CW (deleted)
zsh(197994): C /nix/store/80vwnjjgcrbp41pk927r8lzybjhy0k73-zsh-5.9.1/bin/zsh
[…]
这样我们就能拿到 PID,进而验证是否是多个进程共同导致了历史记录损坏。但是,我们仍然看不到每个 Zsh 进程到底读/写了多少数据,所以即便有了 fatrace 日志,也还是搞不清到底发生了什么。
strace
当然,也可以用 strace(1),特别是加上 -k 标志来进一步观察 Zsh 的行为,但要给每一个(交互式)Zsh 进程都安排上对应的 strace,操作上简直是噩梦,而且我也不确定一直用 strace 跟踪 shell 是否会在细节上改变其行为,所以我没有走 strace 这条路。
(在有了可复现的用例之后,strace 就变得很容易使用,而且非常有帮助。)
bpftrace
为了更清楚地看到 Zsh 的读写操作,我们可以用 bpftrace(8)。
起步时,我写了下面这个 bpftrace 程序,它会在每次 open(2) 系统调用时执行,并记录是哪个进程打开了 .zsh_history 文件,包括用户态堆栈:
tracepoint:syscalls:sys_enter_open,
tracepoint:syscalls:sys_enter_openat,
tracepoint:syscalls:sys_enter_openat2
/str(args.filename) == "/home/michael/.zsh_history" || str(args.filename) == ".zsh_history"/
{
printf("%-6d %-16s open(%s)%s", pid, comm, str(args.filename), ustack);
}
在 NixOS 26.05 上,我可以这样运行这个程序:
midna ~ % nix shell nixpkgs#bpftrace
midna ~ 2 % sudo bpftrace path.bt
Attached 3 probes
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
lockhistfile+642
readhistfile+2213
zsh_main+1118
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
_IO_file_open+51
_IO_file_fopen@@GLIBC_2.2.5+303
__fopen_internal+134
readhistfile+2277
zsh_main+1118
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
lockhistfile+642
savehistfile+165
zexit+204
zsh_main+1522
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
212030 zsh open(/home/michael/.zsh_history)
__internal_syscall_cancel+142
__syscall_cancel+20
__libc_open64+87
_IO_file_open+51
_IO_file_fopen@@GLIBC_2.2.5+303
__fopen_internal+134
readhistfile+2277
savehistfile+2498
zexit+204
zsh_main+1522
__libc_start_call_main+117
__libc_start_main_alias_2+136
_start+37
^C
受到这次初步成功的鼓舞,我又把程序扩展了一下,以覆盖更多的系统调用:
完整的 zshhisttrace.bt bpftrace 代码
#!/usr/bin/bpftrace
#include <fcntl.h>
#include <limits.h>
tracepoint:syscalls:sys_enter_open /comm == "zsh"/ {
printf("%s(%d) open: %s flags %x mode %x\n", comm, pid, str(args->filename), args->flags, args->mode);
}
tracepoint:syscalls:sys_enter_openat {
if (!strcontains(str(args->filename), "zsh_history")) {
delete(@openfn[tid]);
return;
}
@openfn[tid] = 1;
printf("%s(%d) openat: ", comm, pid);
if (args->dfd < 0x7fffffff) { /* ought to be != AT_FDCWD, but that does not work !?!? */
printf("[at fd %d]", args->dfd);
}
printf("%s flags %x mode %x\n", str(args->filename), args->flags, args->mode);
}
tracepoint:syscalls:sys_exit_openat /@openfn[tid]/ {
@reads[tid,(int64)args->ret] = 1; // TODO: bpftrace 0.22 introduces has_key
@writes[tid,(int64)args->ret] = 1; // TODO: bpftrace 0.22 introduces has_key
}
tracepoint:syscalls:sys_enter_close /@reads[tid,(int64)args->fd]/ {
printf("%s(%d) close %d (reads: %d, writes: %d)\n", comm, pid, args->fd, @reads[tid,(int64)args->fd]-1, @writes[tid,(int64)args->fd]-1);
delete(@reads[tid,(int64)args->fd]);
delete(@writes[tid,(int64)args->fd]);
}
tracepoint:syscalls:sys_enter_rename /comm == "zsh"/ {
printf("%s(%d) rename:", comm, pid);
printf("%s -> %s\n", str(args->oldname), str(args->newname));
}
tracepoint:syscalls:sys_enter_symlink /comm == "zsh"/ {
printf("%s(%d) symlink ", comm, pid);
printf("%s -> %s\n", str(args->oldname), str(args->newname));
}
tracepoint:syscalls:sys_enter_unlink /comm == "zsh"/ {
printf("%s(%d) unlink ", comm, pid);
printf("%s\n", str(args->pathname));
}
tracepoint:syscalls:sys_enter_unlinkat /comm == "zsh"/ {
printf("%s(%d) unlinkat ", comm, pid);
printf("%s\n", str(args->pathname));
}
tracepoint:syscalls:sys_enter_lseek /comm == "zsh"/ {
printf("%s(%d) lseek fd %d offset %d whence %d\n", comm, pid, args->fd, args->offset, args->whence);
}
tracepoint:syscalls:sys_enter_read /@reads[tid,(int64)args->fd]/ {
@reads[tid,(int64)args->fd] += args->count;
}
tracepoint:syscalls:sys_exit_read /comm == "zsh"/ {
if (args->ret <= 0) {
printf("%s(%d) read = %d\n", comm, pid, args->ret);
}
}
tracepoint:syscalls:sys_exit_write /comm == "zsh"/ {
if (args->ret <= 0) {
printf("%s(%d) write = %d\n", comm, pid, args->ret);
}
}
tracepoint:syscalls:sys_enter_write /@writes[tid,(int64)args->fd]/ {
@writes[tid,(int64)args->fd] += args->count;
}
如果你想更深入地了解 bpftrace,这里有几份我觉得有用的资料:
- bpftrace 官方文档
- 博文“First steps in system-wide Linux tracing” by Martin Pitt (2020)
- LSFMM 演讲“BPF Observability” by Brendan Gregg (2019)
我创建了一个 systemd 单元来让这个程序在后台一直运行(开销似乎很小),这样我就可以像这样查看日志:
midna % journalctl -fu zshhisttrace
cp(2338700) close 3 (reads: 3407872, writes: 0)
zsh(231222) symlink /pid-231222/host-midna -> /home/michael/.zsh_history.LOCK
zsh(231222) openat: /home/michael/.zsh_history flags 541 mode 180
zsh(231222) close 3 (reads: 0, writes: 0)
zsh(231222) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231222) lseek fd 3 offset 0 whence 1
zsh(231222) read = 0
zsh(231222) close 3 (reads: 52895744, writes: 0)
zsh(231222) unlink /home/michael/.zsh_history.new
zsh(231222) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231222) close 3 (reads: 0, writes: 52888907)
zsh(231222) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history
zsh(231222) unlink /home/michael/.zsh_history.LOCK
有一天,我发现历史记录又被截断了,于是去查了日志。结果如下。请注意其中没有 read = 0 这一行,也就是说 Zsh 并没有一直读到 EOF:
zsh(231233) symlink /pid-231233/host-midna -> /home/michael/.zsh_history.LOCK
zsh(231233) openat: /home/michael/.zsh_history flags 541 mode 180
zsh(231233) close 3 (reads: 0, writes: 0)
zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231233) lseek fd 3 offset 0 whence 1
zsh(231233) lseek fd 3 offset 0 whence 1
zsh(231233) lseek fd 3 offset 11572944 whence 0
zsh(231233) close 3 (reads: 11575296, writes: 0)
zsh(231233) unlink /home/michael/.zsh_history.new
zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231233) close 3 (reads: 0, writes: 11572944)
zsh(231233) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history
zsh(231233) unlink /home/michael/.zsh_history.LOCK
让它崩溃!
从上面的 bpftrace 输出可以看出,Zsh 在重写我的 .zsh_history 文件时出错了:它读取的行数比平时少得多,然后又把这些内容原样写入了 .zsh_history.new。
到这一步,我决定去研究代码,看看为什么 readhistfile 没有把整个历史文件读完,或者为什么 savehistfile 没有把完整的历史写出来。
savehistfile 的控制流程相当难懂,但要修改代码(zsh-5.9.1)让它在写入了一个少于 50000 行的 .zsh_history.new 之后、并在用这个被截断的新文件替换掉我的 .zsh_history 之前崩溃,却很容易:
--- i/Src/hist.c
+++ w/Src/hist.c
@@ -2994,6 +2994,7 @@ savehistfile(char *fn, int err, int writeflags)
if (out) {
char *history_ignore;
Patprog histpat = NULL;
+ int lines_written = 0;
pushheap();
@@ -3048,6 +3049,7 @@ savehistfile(char *fn, int err, int writeflags)
ret = fputc(' ', out);
if (ret < 0 || (ret = fputc('\n', out)) < 0)
break;
+ lines_written++;
}
if (ret >= 0 && start && writeflags & HFILE_USE_OPTIONS) {
struct stat sb;
@@ -3062,6 +3064,10 @@ savehistfile(char *fn, int err, int writeflags)
}
if (fclose(out) < 0 && ret >= 0)
ret = -1;
+ if (tmpfile && lines_written < 50000) {
+ char *crashptr = (char*)0x23;
+ *crashptr = 42;
+ }
if (ret >= 0) {
if (tmpfile) {
if (rename(tmpfile, unmeta(fn)) < 0) {
在 Linux 上,要确保这样的崩溃能被妥善收集,最简单的方法是安装 systemd-coredump(8),之后 systemd 会自动收集 core dump。你可以用 coredumpctl(1) 来列出和处理它们。注意这些 core dump 里包含了你的 shell 历史记录,所以不要上传到第三方服务。Fedora 的 ABRT 似乎只会发送微型报告(即不包含完整的 shell 历史),而 Ubuntu 的 Apport 默认是禁用的,但还是值得再确认一下。
我安装了自己打过补丁的 Zsh 版本(已开启调试符号),然后就把进一步的排查先放一放,等着下一次出现问题的 core dump。果然,几天后我用 coredumpctl 检查时,就看到了一次崩溃!回溯如下:
midna % coredumpctl debug
gdb $ bt full
#0 0x000056040d781e19 in savehistfile (fn=0x56040f7a76b0 "/home/michael/.zsh_history", err=1, writeflags=0) at hist.c:3086
crashptr = 0x23 <error: Cannot access memory at address 0x23>
history_ignore = 0x0
histpat = 0x0
lines_written = 45546
t = 0x5604102a1f59 ""
tmpfile = 0x5604100ec210 "/home/michael/.zsh_history.new"
start = 0x5604102a1f40 "make -j32"
out = 0x56040f939400
he = 0x0
xcurhist = 45546
extended_history = 0
ret = 10
#1 0x000056040d781f72 in savehistfile (fn=0x56040f7a76b0 "/home/michael/.zsh_history", err=1, writeflags=32771) at hist.c:3121
remember_histactive = 0
history_ignore = 0x0
histpat = 0x0
lines_written = 0
t = 0x0
tmpfile = 0x0
start = 0x0
out = 0x56040f939400
he = 0x0
xcurhist = 51183
extended_history = 0
ret = 0
#2 0x000056040d751197 in zexit (val=0, from_where=ZEXIT_NORMAL) at builtin.c:6055
writeflags = 32768
#3 0x000056040d7888e2 in zsh_main (argc=2, argv=0x7ffd370c1758) at init.c:1950
errexit = 0
t = 0x7ffd370c1768
runscript = 0x0
zsh_name = 0x7ffd370c26bd "zsh"
cmd = 0x0
t0 = 162
#4 0x000056040d735d89 in main (argc=2, argv=0x7ffd370c1758) at ./main.c:93
No locals.
回到源码,我意识到很可能 savehistfile 之所以写出了更短的历史文件,只是因为 readhistfile 给它留下了一个更短的历史!
readhistfile 的控制流程要好懂一些。通读这个函数,会发现只有一种提前返回的可能:当 Zsh 收到信号时,读取循环会通过 break; 被中止:
// …
if (errflag & ERRFLAG_INT) {
/* Can't assume fast read next time if interrupted. */
lasthist.interrupted = 1;
break;
}
// …
来看看这次崩溃时 errflag 和 lasthist.interrupted 的值:
gdb $ p errflag
$1 = 2
gdb $ p lasthist.interrupted
$2 = 1
找到了!所以肯定有某个信号参与其中。
出于本文范围之外的原因,我平时会用一个 mosh 会话,在其中启动一个长期运行的 SSH 会话,再在上面复用更多的会话。每天下班收工时,我会在这些复用的会话里按 Ctrl+D(发送 EOF,退出会话),然后在那个长期运行的 SSH 上按 Ctrl+C,接着再按 Ctrl+D 退出 mosh 会话。
(如果不干净地退出 mosh 会话,它会在服务器上一直残留,之后再登录时就会提示有这些孤儿会话。我想避免堆积孤儿会话。)
所以实际上我会反复按 Ctrl+D、Ctrl+C、Ctrl+D、Ctrl+C,直到所有窗口都关掉。在这个过程中,很可能我刚用 Ctrl+D 退出一个 Zsh 会话,紧接着又用 Ctrl+C 中断了它正在进行的 readhistfile,如果历史重写耗时足够长的话。
有了这些线索,我构建了一个独立的复现用例,并在 2025 年 3 月向 zsh-workers 邮件列表发送了 bug 报告。Bart Schaefer 对此进行了研究,并在 2025 年 4 月发布了修复(感谢!)。
这个修复花了很长时间才真正发布,因为中间有很长一段时间没有发布任何 Zsh 版本。然后,等到 5.9.1 发布时,发布工程师竟然遗漏了 Bart 的修复!我指出了这个疏漏,幸好 Zsh 5.9.2 包含了该修复。
我一直在运行打上了 Bart 补丁的 Zsh 5.9,并会保持这个版本不变,直到 5.9.2 登陆我的电脑。如果你要在 Debian 上锁定 zsh 版本,请同时锁定 zsh 和 zsh-common 这两个包。否则,某天你可能会发现 zsh 包整个都没了……
这个 bug 究竟是什么?
退出时,zexit 会调用 savehistfile 来压缩历史记录:在会话期间,历史条目是增量追加的,但在 shell 退出时,历史文件会被压缩(例如为了应用大小限制,如果有配置的话),所以 savehistfile 会读取整个历史(readhistfile)并重新写出一遍。
readhistfile 在收到信号时可能会被中断(它会检查 errflag & ERRFLAG_INT 并提前跳出读取循环),但 savehistfile 在退出时写入 shell 历史时却没有检查是否被中断。因此,savehistfile 写出了(不完整的)历史记录,截断了原本的历史。
让我们来解读一下之前收集到的 bpftrace 输出:
zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0
zsh(231233) lseek fd 3 offset 0 whence 1
# […] reads are aggregated, see below […]
# […] interrupt happens here […]
# lseek(3, 0, SEEK_CUR) = query the current seek offset
zsh(231233) lseek fd 3 offset 0 whence 1
# SEEK_SET at fclose(), as POSIX mandates (see below)
zsh(231233) lseek fd 3 offset 11572944 whence 0
zsh(231233) close 3 (reads: 11575296, writes: 0)
zsh(231233) unlink /home/michael/.zsh_history.new
zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180
zsh(231233) close 3 (reads: 0, writes: 11572944)
zsh(231233) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history
为什么会有 lseek?来自 POSIX.1-2017 对 fclose() 的说明
If the file is not already at EOF, and the file is one capable of seeking, the file offset of the underlying open file description shall be set to the file position of the stream if the stream is the active handle to the underlying file description.
Zsh 使用 fopen() 来获取一个流,所以 glibc 会以 4096 字节为单位分块读取,而在关闭流时,底层文件描述符需要被 seek 回去,以便当前 4096 字节块中已读取的部分能够被下一个流正确地再次读取。(Zsh 会马上关闭文件,所以这个 seek 其实是无意义的,但 glibc 并不知道。)
结论
一个会导致数据丢失的 bug,竟然能在如此流行的 shell 中 10 年都未被修复,这令人惊讶(你知道吗?Apple 在 2019 年就把 macOS 的默认登录 shell 切换成了 Zsh)。
当然,大多数用户可能并不会像我这样以一种很容易触发 SIGINT 的方式来结束 shell 会话,但我敢说,肯定有用户已经丢失过部分历史记录。
我很高兴这个问题现在终于被修复了!如果你也遇到了历史文件被截断的情况,而又不是本文所描述的这个问题,也许是你不小心 export 了 HISTFILE?请参阅附录 A,了解几年前我踩过的另一个关于 HISTFILE 的坑。
在写这篇文章时冒出的另一个显而易见的问题是:我在 LLMs 在编码和解决问题方面变得非常厉害之前就追踪到了这个问题。今天的 AI 编程智能体能找到这个 bug 吗?详情请见附录 B,但答案是:能,今天最前沿的模型就能找到这个 bug!
附录 A:额外的坑:被 export 的 HISTFILE
当你使用 Emacs 的 TRAMP 模式时,它默认会 export HISTFILE。例如,在执行 emacs /ssh:keep:/srv/keep 后再使用 M-x shell,我会在环境变量中看到 HISTFILE:
/ssh:keep:/srv/keep/ #$ env | grep HISTFILE
HISTFILE=/home/michael/.tramp_history
/ssh:keep:/srv/keep/ #$
这是一个坑,因为大多数 shell 配置并不会取消 export HISTFILE,而只是修改它的值。例如,在我的 ~/.zshrc 中,我设置了 HISTFILE=~/.zsh_history。
当运行一个交互式 shell(输入 zsh 后按回车)时,我最终会在环境变量中得到 HISTFILE:
/ssh:keep:/srv/keep/ #$ zsh
locale: Cannot set LC_CTYPE to default locale: No such file or directory
$ env | grep HISTFILE
HISTFILE=/home/michael/.zsh_history
$
……而当我使用 ssh(1) 登录时则不会:
midna ~ % ssh keep
Last login: Sat Aug 1 17:38:37 2026 from 100.64.1.1
keep ~ % env | grep HISTFILE
keep ~ %
在一个会为其他 shell 配置不同(默认)设置的机器上,export 一个 shell 专属的 HISTFILE 是个坑。在我的工作电脑上,Linux 发行版默认为 bash 设置了 HISTSIZE=64000 和 HISTFILESIZE=64000,有一次我就这样不小心把自己的 ~/.zsh_history 文件截断到了 64000 行。我怀疑当时的操作是运行了 M-x shell,然后执行 zsh(以加载我的配置),再临时执行 bash(为了 source 某个配置并启动脚本)。
为了今后避免这类问题,我决定在 ~/.zshrc 中主动取消 export HISTFILE。
附录 B:额外问题:AI 能找到这个 bug 吗?
有一段时间,我一直想亲手尝试创建自己的评测。参见 Anthropic 的《Demystifying evals for AI agents》,如果你还不熟悉“评测(eval)”这个词的话。
我一开始尝试了 Simon Willison 的 smevals,但发现它过于简陋:如果不采取额外措施,智能体很快就会跳出评测任务去偷看答案,或者利用互联网发现 Zsh 的 git 版本已经修复了这个 bug。
最后我选用了 Inspect,一个开源评测框架,它由英国 AI 安全研究所和 Meridian Labs 开发,效果更好,尽管它的网页界面非常简陋。
这个评测很快就变得非常昂贵!我为大约 3 次评测尝试支付了超过 300 美元的 token 费用。下面的结果来自最近一次尝试。当模型正确解释了事件的完整序列:中断设置了 errflag,导致 readhistfile 中止并产生被截断的历史文件时,即视为通过。
评测设置:症状 + bpftrace
完整提示词,包括正常/被截断的 bpftrace
when i log out, sometimes when i come back the next day my .zsh_history file is mysteriously truncated. why might that be?
I’m on zsh 5.9.1 on Linux. Only zsh ever writes this file. I have a bpftrace program logging every syscall zsh makes against the history file.
A NORMAL logout looks like this:
zsh(231222) symlink /pid-231222/host-midna -> /home/michael/.zsh_history.LOCK zsh(231222) openat: /home/michael/.zsh_history flags 541 mode 180 zsh(231222) close 3 (reads: 0, writes: 0) zsh(231222) openat: /home/michael/.zsh_history flags 0 mode 0 zsh(231222) lseek fd 3 offset 0 whence 1 zsh(231222) read = 0 zsh(231222) close 3 (reads: 52895744, writes: 0) zsh(231222) unlink /home/michael/.zsh_history.new zsh(231222) openat: /home/michael/.zsh_history.new flags c1 mode 180 zsh(231222) close 3 (reads: 0, writes: 52888907) zsh(231222) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history zsh(231222) unlink /home/michael/.zsh_history.LOCKA logout that TRUNCATED the file looks like this:
zsh(231233) symlink /pid-231233/host-midna -> /home/michael/.zsh_history.LOCK zsh(231233) openat: /home/michael/.zsh_history flags 541 mode 180 zsh(231233) close 3 (reads: 0, writes: 0) zsh(231233) openat: /home/michael/.zsh_history flags 0 mode 0 zsh(231233) lseek fd 3 offset 0 whence 1 zsh(231233) lseek fd 3 offset 0 whence 1 zsh(231233) lseek fd 3 offset 11572944 whence 0 zsh(231233) close 3 (reads: 11575296, writes: 0) zsh(231233) unlink /home/michael/.zsh_history.new zsh(231233) openat: /home/michael/.zsh_history.new flags c1 mode 180 zsh(231233) close 3 (reads: 0, writes: 11572944) zsh(231233) rename:/home/michael/.zsh_history.new -> /home/michael/.zsh_history zsh(231233) unlink /home/michael/.zsh_history.LOCKmy zshrc is in ./zshrc — the exact config in effect on the affected machine, so you can see which options are (and aren’t) enabled.
The full zsh 5.9.1 source tree is available in ./zsh-5.9.1 — this is exactly the version I’m running. Dig into it as much as you need.
What’s going on, and what in the zsh source would cause it?
Work only from the zsh 5.9.1 source provided and the evidence above. Do not consult newer zsh versions, upstream commits, mailing-list threads, changelogs or release notes — the point is to derive the cause from this source, not to look up how it was later fixed.
End your reply with a section headed exactly
## Diagnosiscontaining your final answer: the root cause, and the specific code responsible.
| 得分 | 模型 | 令牌数 | 耗时 |
|---|---|---|---|
| ✅ 3 of 3 | openai/gpt-5.6-sol | 497,040 | 2m 29s |
| ✅ 3 of 3 | anthropic/claude-opus-5 | 5,440,676 | 26m 43s |
| ⚠️ 2 of 3 | openai/gpt-5.5 | 659,050 | 2m 43s |
| ⚠️ 1 of 3 | openai/gpt-5.6-terra | 673,336 | 1m 57s |
| ⚠️ 1 of 3 | google/gemini-3.1-pro-preview | 3,733,226 | 9m 31s |
| ⚠️ 1 of 3 | anthropic/claude-sonnet-5 | 9,323,989 | 29m 18s |
| ⚠️ 1 of 3 | google/gemini-3.5-flash | 6,746,305 | 12m 38s |
| ⚠️ 1 of 3 | moonshotai/kimi-k3 (open weight!) @ medium | 2,455,874 | 45m 6s |
| ⚠️ 1 of 3 | moonshotai/kimi-k3 (open weight!) @ high | 13,060,937 | 52m 2s |
| ⚠️ 1 of 3 | google/gemini-3-flash-preview | 19,370,711 | 30m 14s |
| ❌ | openai/gpt-5.1 | 118,948 | 1m 12s |
| ❌ | openai/gpt-5.4 | 286,288 | 1m 10s |
| ❌ | openai/gpt-5.6-luna | 549,724 | 1m 14s |
| ❌ | qwen/qwen3-coder | 623,110 | 5m 2s |
| ❌ | openai/gpt-5.2 | 1,306,586 | 1m 49s |
| ❌ | openai/gpt-5 | 2,858,548 | 7m 14s |
| ❌ | anthropic/claude-opus-4-8 | 3,402,954 | 9m 5s |
| ❌ | deepseek/deepseek-v4-flash-0731 (open weight!) | 5,570,798 | 25m 6s |
| ❌ | google/gemini-3.1-flash-lite | 6,945,359 | 2m 7s |
| ❌ | qwen/qwen3.8-max (open weight!) | 6,307,959 | 39m 32s |
| ❌ | deepseek/deepseek-v4-pro (open weight!) | 8,577,105 | 30m 7s |
| ❌ | anthropic/claude-haiku-4-5 | 10,646,235 | 7m 24s |
| ❌ | qwen/qwen3.6-max-preview | 19,689,360 | 27m 12s |
| ❌ | minimax/minimax-m3 (open weight!) | 19,937,971 | 46m 28s |
| ❌ | z-ai/glm-5.2 (open weight!) | 21,507,452 | 29m 30s |
评测变体:带习惯提示
在这个变体中,我加入了关于反复按 Ctrl+C 和 Ctrl+D 的习惯提示,这算是对信号和中断处理的一个暗示:
fwiw, my logout habit: i press ctrl+c / ctrl+d repeatedly until all my terminal windows are gone, and then see what’s left.
这可以衡量模型在有提示的情况下,能否更轻松地理解问题。
| 得分 | 模型 | 令牌数 | 耗时 |
|---|---|---|---|
| ✅ 3 of 3 | openai/gpt-5.6-sol | 393,895 | 1m 43s |
| ✅ 3 of 3 | openai/gpt-5.5 | 622,081 | 1m 31s |
| ✅ 3 of 3 | anthropic/claude-opus-5 | 1,583,601 | 7m 29s |
| ✅ 3 of 3 | anthropic/claude-opus-4-8 | 2,338,905 | 6m 4s |
| ✅ 3 of 3 | anthropic/claude-sonnet-5 | 3,132,322 | 14m 9s |
| ✅ 3 of 3 | moonshotai/kimi-k3 @ medium (open weight!) | 4,642,764 | 32m 25s |
| ✅ 3 of 3 | moonshotai/kimi-k3 @ high (open weight!) | 9,631,629 | 32m 17s |
| ✅ 3 of 3 | z-ai/glm-5.2 (open weight!) | 23,654,094 | 22m 37s |
| ⚠️ 2 of 3 | openai/gpt-5 | 2,345,526 | 3m 38s |
| ⚠️ 2 of 3 | google/gemini-3-flash-preview | 6,421,624 | 16m 41s |
| ⚠️ 2 of 3 | google/gemini-3.5-flash | 3,571,679 | 9m 7s |
| ⚠️ 2 of 3 | qwen/qwen3.8-max (open weight!) | 4,289,195 | 41m 49s |
| ⚠️ 1 of 3 | openai/gpt-5.6-luna | 426,974 | 1m 26s |
| ⚠️ 1 of 3 | openai/gpt-5.6-terra | 728,604 | 1m 31s |
| ⚠️ 1 of 3 | google/gemini-3.1-pro-preview | 3,525,585 | 6m 23s |
| ⚠️ 1 of 3 | deepseek/deepseek-v4-flash-0731 (open weight!) | 3,628,997 | 25m 37s |
| ⚠️ 1 of 3 | deepseek/deepseek-v4-pro (open weight!) | 7,751,190 | 30m 4s |
| ❌ | openai/gpt-5.4 | 266,719 | 45s |
| ❌ | openai/gpt-5.1 | 287,721 | 1m 9s |
| ❌ | openai/gpt-5.2 | 1,097,940 | 1m 30s |
| ❌ | qwen/qwen3-coder (open weight!) | 1,160,681 | 8m 31s |
| ❌ | anthropic/claude-haiku-4-5 | 5,663,995 | 5m 47s |
| ❌ | google/gemini-3.1-flash-lite | 5,839,696 | 1m 39s |
| ❌ | minimax/minimax-m3 (open weight!) | 10,733,126 | 27m 25s |
| ❌ | qwen/qwen3.6-max-preview | 13,322,764 | 30m 4s |
AI 结论
像 Claude Opus 5 或 GPT 5.6 Sol 这样的最新前沿模型,仅凭对症状的描述和一份正常/异常的 bpftrace 日志,就能可靠地找到这个 bug。如果你多试几次,Gemini 系列模型也能做到。在开源权重模型中,只有 Kimi K3 能在没有提示的情况下找到这个 bug。
一旦在提示词中加入了反复按 Ctrl+C + Ctrl+D 的习惯,更多前沿模型就能可靠地找到问题(包括 Claude Sonnet 5!)。在开源权重模型中,GLM 5.2 和 Kimi K3 是最先能可靠地搞清问题的!如果你多试几次,Gemini 或 DeepSeek 模型也能做到。我没能让 Qwen 或 Minimax 模型通过。
这似乎是一个非常不错的评测,特别是用来追踪哪个开源权重模型真正能达到 Opus 或 GPT 的水平(至少在这一特定方面)。目前来看,Kimi K3 似乎是最强的开源权重模型,尽管它也无法可靠地诊断出这个问题。GLM 5.2 体量要小得多——在有提示的情况下——至少还能理解这个问题。
值得注意的是,几乎所有模型都考虑过正确的假设,包括 Qwen 和 Minimax 模型。只有 Gemini 3.1 Flash Lite 从未提出过正确的假设,大概是因为与其他模型相比它是个小模型。
那么模型是在哪里出错的呢?在验证/证伪理论上!例如,GLM 5.2 认定 bpftrace 输出中的 lseek 一定意味着 SHAREHISTORY 被启用了(实际上并没有!):
glm-5.2 enumerated exactly three causes of a short read — corruption,
HFILE_FASTsearching,errflag & ERRFLAG_INT— then ruled out the interrupt because “Options 1 and 3 don’t involve lseek to a non-zero offset. But the trace showslseek(offset, SEEK_SET), which isHFILE_FASTbehavior. SoSHAREHISTORYmust be set” — overriding yourzshrc’sunsetopt SHARE_HISTORYto keep the elimination alive.
我验证过,通过增加编排(让一个子智能体负责提出理论,另一个负责跟踪并证伪/验证,等等),成功率会提高。同样,我预计通过调整提示词和执行框架,单个模型的表现也能得到大幅提升。
最常见的失败模式似乎是模型选错了理论,然后就卡在验证它上面,再也没有回到其他理论上。也许表现更好的模型拥有更好的方法论,即它们更严格地遵循了科学方法?
随机一篇博客
评论
登录后参与讨论