Tracking down a Zsh history data loss bug 🐞

Michael Stapelberg

追踪 Zsh 历史记录丢失 bug 🐞

多年来,我偶尔会发现,一些我确信自己执行过的命令,已经不再出现在我的Z shell历史文件(~/.zsh_history)中。本文将介绍我是如何追踪到这个 bug 的。先剧透一下:最终,给 Zsh 打补丁,让它大声崩溃,再分析崩溃的 core dump(核心转储),是最有效的策略!

先说好消息

Zsh 5.9.2(发布于 2026 年 7 月 12 日)已经包含了这个问题的修复——请在读完这篇调查之后再打开它,以免提前失去乐趣。

剧透:上游修复链接

Zsh 修复 53454

症状

有时我会注意到,前一天我明明知道自己执行过的命令,在 shell 历史中却找不到了,也就是说,按下 Ctrl+R 进行向后历史搜索时不会得到任何结果。每当我发现这种情况时,我的 shell 历史文件中只剩下非常老的条目,之后几年新增的条目都不见了。

最初几次发生这种情况时,我只是从每日备份中恢复 shell 历史,完全没有进一步调查。但这个问题一直反复出现。

我注意到,.zsh_history 中没有明显的损坏(没有不可打印字符,也没有不完整的文本行),而且文件中的行数也并不总是相同。

当时我还不清楚,究竟是 Zsh 本身、其他某个程序,还是多个 zsh(1) 进程共同作用导致了这个问题。

我的 Zsh 历史配置

我在 ~/.zshrc 中设置了以下与历史相关的选项:

# 加载 4000 行历史(用于 Ctrl+R 向后搜索),但保存 O(∞) 行历史
HISTSIZE=4000
HISTFILE=~/.zsh_history
SAVEHIST=10000000

# 不保存(相邻的)重复条目
setopt HIST_IGNORE_DUPS

# 执行命令时,将历史条目追加到 `~/.zsh_history`。
setopt INC_APPEND_HISTORY
# ……但不共享历史(NixOS 的 /etc/zshrc 默认启用该选项)。
unsetopt SHARE_HISTORY

实际上,这意味着我的 shell 是彼此独立的会话,但都会将命令流式写入同一个 ~/.zsh_history。历史记录并不会自动共享,因此,当我想访问另一个 shell 写入的条目时,会显式运行 exec zsh

追踪行为

2024 年 12 月,我在 Mastodon 上求助(主要是希望有人已经遇到并诊断过这个问题),得到的一个建议是使用 inotify 或 fsevents 之类的文件系统变更监控机制,找出截断(或修改?)Zsh 历史文件的元凶。

接下来的几节将介绍我在 Linux 上尝试过的各种方案。

inotify

inotify(7) Linux 内核子系统是 Linux 中最早可用的文件系统变更监控 API 之一(发布于 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,因此我们现在可以确认是否有多个进程参与了 shell 历史的损坏。但我们并不知道每个 Zsh PID 读写了多少数据,所以即使有一份 fatrace 日志,也仍然无法确定究竟发生了什么。

strace

当然,也可以使用 strace(1),尤其是配合其 -k 标志,以进一步了解 Zsh 的行为。不过,要安排每个(交互式)Zsh 进程都对应运行一个 strace,似乎是一场后勤噩梦;我也不确定始终对 shell 运行 strace 是否会以微妙的方式改变其行为,因此没有继续探索 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
__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
savehistfile+752
zexit+204
__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,下面是一些我觉得有用的资源:

我创建了一个 systemd unit,让它永久在后台运行这个程序(看起来成本足够低),这样我就可以像下面这样检查日志:

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

有一天,我注意到 shell 历史被截断了,于是查看了日志。以下就是我发现的内容。注意,这里没有 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;
}
// …

来看看这次崩溃中 errflaglasthist.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,直到所有窗口都消失。这个过程很可能会出现这样的情况:我退出一个 Zsh 会话(Ctrl+D),随后又中断了它的 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 版本,请同时固定 zshzsh-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

# […] 读取量经过聚合,见下文 […]
# […] 中断发生在这里 […]

# lseek(3, 0, SEEK_CUR) = 查询当前 seek 偏移量
zsh(231233) lseek fd 3 offset 0 whence 1
# fclose() 中的 SEEK_SET,POSIX 要求如此(见下文)
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() 的规定

如果文件尚未位于 EOF,且该文件支持 seek,那么底层打开文件描述的文件偏移量应设置为流的文件位置,前提是该流是底层文件描述的活动句柄。

Zsh 使用 fopen() 获取一个流,因此 glibc 会以每次 4096 字节的块读取;关闭该流时,底层文件描述符需要 seek 回去,这样当前 4096 字节块中已经读取的部分,才能被下一个流再次正确读取。(Zsh 会立即关闭文件,所以这个 seek 实际上没有意义,但 glibc 无法知道这一点。)

结论

一个会导致数据丢失的 bug,竟然能在一个流行 shell 中持续 10 年而未被修复,实在令人惊讶。(你知道吗?Apple 在 2019 年将 macOS 的默认登录 shell 切换成了 Zsh。)

当然,大多数用户可能没有我这种会以容易发送 SIGINT 的方式终止 shell 会话的习惯,但我不得不认为,确实有一些用户丢失过部分历史记录。

我非常高兴这个问题现在已经修复了!如果你也遇到了历史文件被截断的问题,而原因不是本文描述的这个问题,那么也许是你不小心导出了 HISTFILE?附录 A 介绍了我几年前遇到的另一个与 HISTFILE 有关的危险陷阱。

写这篇文章时还出现了另一个显而易见的问题:我是在 LLM 的编程和问题解决能力还没有变得惊人地强之前追踪到这个问题的。如今的 AI 编程 agent 能找到这个 bug 吗?详情请见附录 B,不过答案是:如今最先进的 AI 模型能够找到这个 bug!

附录 A:额外陷阱:导出的 HISTFILE

使用 Emacs 的 TRAMP 模式时,它默认会导出 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 配置不会取消导出 HISTFILE,而只是修改它。例如,在我的 ~/.zshrc 中,我设置了 HISTFILE=~/.zsh_history

运行交互式 shell(输入 zsh 后按 Enter)时,环境中就会有 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 使用了不同(默认)配置的机器上,导出 shell 专用的 HISTFILE 是一个危险陷阱。在我的工作电脑上,Linux 安装默认会为 bash 设置 HISTSIZE=64000HISTFILESIZE=64000,我曾经不小心把 ~/.zsh_history 文件截断成了 64000 行。我怀疑当时的操作顺序是:运行 M-x shell,然后运行 zsh(以加载我的配置),再运行 bash(暂时使用它来加载一个配置并启动脚本)。

为了今后避免这类问题,我决定在我的 ~/.zshrc 中主动取消导出 HISTFILE

附录 B:额外问题:AI 能找到这个 bug 吗?

有一段时间以来,我一直觉得亲自动手创建自己的 eval 很有用。如果你不熟悉“eval”这个词,可以参阅 Anthropic 的文章 “Demystifying evals for AI agents”

我从 Simon Willison(西蒙·威利森)的 smevals 开始,但发现它过于简陋:如果不采取额外措施,agent 很快就会逃离 eval 任务、偷看答案,或者使用互联网发现 Zsh 的 git 版本已经修复了这个 bug。

最后我采用了英国 AI 安全研究所和 Meridian Labs 开发的开源 eval 框架 Inspect,效果更好,不过它的网页 UI 也非常简陋。

这次 eval 很快就变得非常昂贵!大约进行了 3 次尝试,我支付的 token 成本远超 300 美元。下面的结果来自最近一次尝试。当模型解释出正确的事件顺序时,即中断设置了 errflag,导致 readhistfile 中止,最终产生截断的历史文件,就会判定通过。

Eval 设置:症状 + bpftrace

完整提示词,包括正常/截断时的 bpftrace

我注销后,第二天回来时,有时会发现我的 .zsh_history 文件莫名其妙地被截断了。这可能是什么原因?

我在 Linux 上使用 zsh 5.9.1。只有 zsh 会写入这个文件。我有一个 bpftrace 程序,记录 zsh 针对历史文件执行的每个系统调用。

正常注销时看起来是这样:

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

导致文件被截断的注销过程看起来是这样:

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

我的 zshrc 位于 ./zshrc——这是受影响机器上实际生效的完整配置,因此你可以看到哪些选项启用了、哪些没有启用。

完整的 zsh 5.9.1 源代码树位于 ./zsh-5.9.1——这正是我正在运行的版本。你可以按需深入研究。

究竟发生了什么?zsh 源代码中的什么部分会导致这种情况?

只能依据提供的 zsh 5.9.1 源代码和上面的证据进行分析。不要查阅更新版本的 zsh、上游提交、邮件列表讨论、变更日志或发行说明——重点是从这份源代码中推导出原因,而不是查找后来如何修复。

请以一个标题严格为 ## Diagnosis 的章节结束你的回答,并在其中给出最终答案:根本原因,以及具体负责的代码。

得分模型tokens耗时
✅ 3 of 3openai/gpt-5.6-sol497,0402m 29s
✅ 3 of 3anthropic/claude-opus-55,440,67626m 43s
⚠️ 2 of 3openai/gpt-5.5659,0502m 43s
⚠️ 1 of 3openai/gpt-5.6-terra673,3361m 57s
⚠️ 1 of 3google/gemini-3.1-pro-preview3,733,2269m 31s
⚠️ 1 of 3anthropic/claude-sonnet-59,323,98929m 18s
⚠️ 1 of 3google/gemini-3.5-flash6,746,30512m 38s
⚠️ 1 of 3moonshotai/kimi-k3 (open weight!) @ medium2,455,87445m 6s
⚠️ 1 of 3moonshotai/kimi-k3 (open weight!) @ high13,060,93752m 2s
⚠️ 1 of 3google/gemini-3-flash-preview19,370,71130m 14s
openai/gpt-5.1118,9481m 12s
openai/gpt-5.4286,2881m 10s
openai/gpt-5.6-luna549,7241m 14s
qwen/qwen3-coder623,1105m 2s
openai/gpt-5.21,306,5861m 49s
openai/gpt-52,858,5487m 14s
anthropic/claude-opus-4-83,402,9549m 5s
deepseek/deepseek-v4-flash-0731 (open weight!)5,570,79825m 6s
google/gemini-3.1-flash-lite6,945,3592m 7s
qwen/qwen3.8-max (open weight!)6,307,95939m 32s
deepseek/deepseek-v4-pro (open weight!)8,577,10530m 7s
anthropic/claude-haiku-4-510,646,2357m 24s
qwen/qwen3.6-max-preview19,689,36027m 12s
minimax/minimax-m3 (open weight!)19,937,97146m 28s
z-ai/glm-5.2 (open weight!)21,507,45229m 30s

Eval 变体:提示操作习惯

在这次迭代中,我加入了关于反复按 Ctrl+C 和 Ctrl+D 的提示,将调查方向引向信号和中断处理:

顺便说一下,我的注销习惯是:反复按 ctrl+c / ctrl+d,直到所有终端窗口都消失,然后看看还剩下什么。

这衡量的是模型理解问题的难易程度,如果它们确实能够理解的话。

得分模型tokens耗时
✅ 3 of 3openai/gpt-5.6-sol393,8951m 43s
✅ 3 of 3openai/gpt-5.5622,0811m 31s
✅ 3 of 3anthropic/claude-opus-51,583,6017m 29s
✅ 3 of 3anthropic/claude-opus-4-82,338,9056m 4s
✅ 3 of 3anthropic/claude-sonnet-53,132,32214m 9s
✅ 3 of 3moonshotai/kimi-k3 @ medium (open weight!)4,642,76432m 25s
✅ 3 of 3moonshotai/kimi-k3 @ high (open weight!)9,631,62932m 17s
✅ 3 of 3z-ai/glm-5.2 (open weight!)23,654,09422m 37s
⚠️ 2 of 3openai/gpt-52,345,5263m 38s
⚠️ 2 of 3google/gemini-3-flash-preview6,421,62416m 41s
⚠️ 2 of 3google/gemini-3.5-flash3,571,6799m 7s
⚠️ 2 of 3qwen/qwen3.8-max (open weight!)4,289,19541m 49s
⚠️ 1 of 3openai/gpt-5.6-luna426,9741m 26s
⚠️ 1 of 3openai/gpt-5.6-terra728,6041m 31s
⚠️ 1 of 3google/gemini-3.1-pro-preview3,525,5856m 23s
⚠️ 1 of 3deepseek/deepseek-v4-flash-0731 (open weight!)3,628,99725m 37s
⚠️ 1 of 3deepseek/deepseek-v4-pro (open weight!)7,751,19030m 4s
openai/gpt-5.4266,71945s
openai/gpt-5.1287,7211m 9s
openai/gpt-5.21,097,9401m 30s
qwen/qwen3-coder (open weight!)1,160,6818m 31s
anthropic/claude-haiku-4-55,663,9955m 47s
google/gemini-3.1-flash-lite5,839,6961m 39s
minimax/minimax-m3 (open weight!)10,733,12627m 25s
qwen/qwen3.6-max-preview13,322,76430m 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 模型通过测试。

这似乎是一个非常不错的 eval,尤其适合追踪哪些开放权重模型实际上能像 Opus 或 GPT 一样工作(至少在这一项特定能力上)。目前,Kimi K3 似乎是能力最强的开放权重模型,尽管它无法可靠地诊断这个问题。GLM 5.2 小得多,但在提供提示后,至少能够理解这个问题。

有趣的是,几乎所有模型都考虑过正确的假设,包括 Qwen 和 Minimax 模型。只有 Gemini 3.1 Flash Lite 从未说出正确的假设,大概是因为它是一个较小的模型(相较而言)。

那么,模型究竟错在哪里?错在验证或证伪理论!例如,GLM 5.2 认定 bpftrace 输出中的 lseek一定意味着设置了 SHAREHISTORY(实际上并没有):

glm-5.2 准确列出了短读的三个原因——损坏、HFILE_FAST 搜索、errflag & ERRFLAG_INT——然后因为“选项 1 和 3 都不会涉及 seek 到非零偏移量。但跟踪显示了 lseek(offset, SEEK_SET),这是 HFILE_FAST 的行为。因此一定设置了 SHAREHISTORY”,而排除了中断;它为了维持这一排除结论,甚至无视了你的 zshrc 中的 unsetopt SHARE_HISTORY

我通过让 eval 使用更多编排方式进行了验证(例如让一个子 agent 提出理论,让另一个负责记录并证伪/验证等),结果成功率有所提高。同样,我预计通过改变提示词和 harness,单个模型也能表现得好得多。

最常见的失败模式似乎是:模型选中了错误的理论,然后陷入对该理论的验证,始终不会回头考虑其他理论。也许表现更好的模型拥有更好的方法论,因为它们更能遵循科学方法?

原文由 Michael Stapelberg 发布

本文章由 openai/gpt-5.6-luna 进行翻译