Tracking down a Zsh history data loss bug 🐞

Michael Stapelberg

追踪 Zsh 历史记录丢失的 Bug 🐞

原文由 Michael Stapelberg 发布,订阅该博客

多年来,我时不时会发现一些明明执行过的命令,在 Z shell 的历史文件(~/.zsh_history)里不翼而飞了。在本文中,我会展示自己是如何一步步追踪到这个 bug 的。先剧透一下:最终奏效的办法是给 Zsh 打上一个会让它立刻崩溃的补丁,然后分析崩溃产生的 core dump!

先说好消息

Zsh 5.9.2(2026 年 7 月 12 日发布)已经包含了对这个问题的修复——请在读完本文的排查过程之后再去查看,以免提前剧透、影响乐趣。

剧透:上游修复链接

Zsh fix 53454

症状

偶尔我会发现,自己明明前一天执行过的命令,在 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,这里有几份我觉得有用的资料:

我创建了一个 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;
	}
	// …

来看看这次崩溃时 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,直到所有窗口都关掉。在这个过程中,很可能我刚用 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 版本,请同时锁定 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

# […] 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=64000HISTFILESIZE=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.LOCK

A 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.LOCK

my 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 ## Diagnosis containing your final answer: the root cause, and the specific code responsible.

得分模型令牌数耗时
✅ 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

评测变体:带习惯提示

在这个变体中,我加入了关于反复按 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 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 模型通过。

这似乎是一个非常不错的评测,特别是用来追踪哪个开源权重模型真正能达到 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_FAST searching, 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 shows lseek(offset, SEEK_SET), which is HFILE_FAST behavior. So SHAREHISTORY must be set” — overriding your zshrc’s unsetopt SHARE_HISTORY to keep the elimination alive.

我验证过,通过增加编排(让一个子智能体负责提出理论,另一个负责跟踪并证伪/验证,等等),成功率会提高。同样,我预计通过调整提示词和执行框架,单个模型的表现也能得到大幅提升。

最常见的失败模式似乎是模型选错了理论,然后就卡在验证它上面,再也没有回到其他理论上。也许表现更好的模型拥有更好的方法论,即它们更严格地遵循了科学方法?

本文章由 muse-spark-1.2-contributor 进行翻译

评论