← 文章 / 编程开发
Lobsters 1小时前 · 2026-08-09 20:30:17 · 0 阅读

追踪 Zsh 历史记录数据丢失的 Bug

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

发布于 2026-08-09 标签 调试 编辑图标 目录

多年来,我偶尔会发现一些我确信执行过的命令,却在我的 Z shell 历史文件(~/.zsh_history)中消失了。在本文中,我将展示我是如何追踪这个 Bug 的。剧透一下:最终,通过修补 Zsh 使其崩溃并分析崩溃的核心转储,才是制胜策略!

先报个好消息

Zsh 5.9.2(2026 年 7 月 12 日发布)已修复此问题——建议在读完这篇调查之后再打开它,以免剧透乐趣。

剧透:上游修复链接

Zsh 修复 53454

症状

偶尔,我会发现前一天明明执行过的命令,在 shell 历史中却找不到,这意味着按 Ctrl+R 进行反向历史搜索时毫无结果。每当注意到这一点,我的 shell 历史文件里就只剩下非常旧的条目,多年的新条目都不见了。

最初几次遇到这种情况,我只是从每日备份中恢复 shell 历史,没有深究。但问题反复出现。

我注意到 .zsh_history 中并没有明显的损坏(没有不可打印字符或残缺文本行),而且文件中的行数也并非总是一致。

我不清楚的是,问题究竟出在 Zsh 本身、其他程序,还是多个 zsh(1) 进程的组合导致的。

我的 Zsh 历史配置

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

```html
# 加载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
设置监控。
监控已建立。
.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),即使是使用基于 fanotify(7) 的兄弟工具 fsnotifywait(1) 也不行,尽管这个 API 确实提供了这些信息!我检查过,内核确实发送了 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)`](https://manpages.debian.org/strace.1) 及其 `-k` 标志来进一步观察 Zsh 的行为,但为每个(交互式)Zsh 进程安排相应的 strace 运行似乎是个后勤噩梦,而且我不确定总是对 shell 进行 strace 是否会在细微之处改变其行为,所以我没有走 `strace` 这条路。 (一旦我有了可复现的案例,`strace` 就变得容易使用且非常有帮助。)

bpftrace

为了更深入地了解 Zsh 的读写操作,我们可以借助 [`bpftrace(8)`](https://manpages.debian.org/bpftrace.8)。 首先,我创建了以下 bpftrace 程序,它在每次 [`open(2)`](https://manpages.debian.org/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
已附加 3 个探针
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
        savehistfile+752
        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) { /* 应当为 != AT_FDCWD,但这样写不生效!?!? */
         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 引入 has_key
     @writes[tid,(int64)args->ret] = 1; // TODO: bpftrace 0.22 引入 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_openat2 /comm == "zsh"/ {
//      printf("%s(%d) openat: ", comm, pid);
//      if (args->dfd < INT_MAX) { /* 应当为 != AT_FDCWD,但这样写不生效!?!? */
//          printf("[at fd %d]", args->dfd);
//      }
//      printf("%s \n", str(args->filename));
// }

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]/ {
     // printf("%s(%d) read fd %d size %d\n", comm, pid, args->fd, args->count);
     @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]/ {
     // printf("%s(%d) write fd %d size %d\n", comm, pid, args->fd, args->count);
     @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

有一天,我注意到 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 会自动收集核心转储。你可以使用 coredumpctl(1) 来列出和处理这些转储。请注意,这些核心转储包含你的 shell 历史,因此不要将它们上传到第三方服务。Fedora 的 ABRT 似乎只发送微型报告(即不包含完整的 shell 历史),而 Ubuntu 的 Apport 默认是禁用的,但最好还是再确认一下。

我安装了打过补丁的 Zsh(启用了调试符号),并推迟了进一步调查,直到拿到问题发生时的核心转储。果不其然,几天后我用 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) {
		/* 如果被中断,下次不能假设能快速读取。 */
		lasthist.interrupted = 1;
		break;
	}
	// …
让我们看看崩溃时 `errflag` 和 `lasthist.interrupted` 里有什么: ```gdb 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)后,如果历史重写耗时较长,会中断(Ctrl+C)它的 `readhistfile`。 有了这些线索,我构建了一个独立的复现程序,并在 2025 年 3 月向 zsh-workers 邮件列表发送了错误报告。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

# […] 读取操作已聚合,见下文 […]
# […] 中断发生在此处 […]

# 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 中十年未修复(你知道吗?苹果在 2019 年将 macOS 的默认登录 shell 切换为 Zsh)。

诚然,大多数用户可能没有我那种以容易触发 SIGINT 的方式终止 shell 会话的习惯,但我不得不设想,有些用户确实丢失了部分历史记录。

我非常高兴这个问题现在已经被修复了!如果你也遇到了历史文件被截断的问题,但并不是我在本文中描述的情况,那么也许你不小心导出了 `HISTFILE`?请参阅附录 A,了解我在几年前遇到的一个 `HISTFILE` 额外陷阱。 在撰写这篇文章时,另一个显而易见的问题是:我在 LLM 在编码和问题解决方面变得异常出色之前就追踪到了这个问题。今天的人工智能编码代理能否找到这个 bug?详见附录 B,但答案是:是的,当今的前沿模型能够找到这个 bug!

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

当你使用 Emacs 的 [TRAMP 模式](https://wikemacs.org/wiki/TRAMP)时,默认情况下它会导出 `HISTFILE`。例如,在启动 `emacs /ssh:keep:/srv/keep` 后使用 `M-x shell`,我会在环境中看到 `HISTFILE`: ```bash /ssh:keep:/srv/keep/ #$ env | grep HISTFILE HISTFILE=/home/michael/.tramp_history /ssh:keep:/srv/keep/ #$ ``` 这是一个陷阱,因为大多数 shell 配置不会取消导出 `HISTFILE`,它们只会修改它。例如,在我的 `~/.zshrc` 中,我设置了 `HISTFILE=~/.zsh_history`。 当运行交互式 shell(输入 `zsh` 后按回车)时,我最终会在环境中看到 `HISTFILE`: ```bash /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)](https://manpages.debian.org/ssh.1) 登录时,情况并非如此: ```bash 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 安装默认设置了 `HISTSIZE=64000` 和 `HISTFILESIZE=64000` 给 `bash`,我曾经不小心将 `~/.zsh_history` 文件截断为 64000 行。我怀疑这是因为先运行了 `M-x shell`,然后运行 `zsh`(以获取我的配置),再运行 `bash`(临时,以加载配置并启动脚本)导致的。

为了防止将来再出现这类问题,我决定在 ~/.zshrc主动取消导出 HISTFILE

附录 B:附加问题:AI 能发现这个 bug 吗?

最近一段时间,我觉得亲手创建自己的 evals 会很有用。如果你对“eval”这个术语不熟悉,可以参考 Anthropic 的“Demystifying evals for AI agents”

我最初尝试了 Simon Willison 的 smevals,但发现它过于简陋:如果不采取额外措施,代理很容易跳出 eval 任务去偷看答案,或者上网搜索发现 Zsh 的 git 版本已经修复了这个 bug。

最终我采用了 Inspect,一个由英国 AI 安全研究所和 Meridian Labs 开发的开源 eval 框架,效果更好,尽管它的网页界面非常简洁。

这个 eval 很快就变得非常昂贵!我大约尝试了 3 次,光 token 费用就超过了 300 美元。以下结果是最近一次尝试的。当模型能正确解释事件发生的顺序时,就算通过:中断设置 errflag,导致 readhistfile 中止,最终造成历史文件被截断。

Eval 设置:症状 + bpftrace

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

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

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

正常的注销过程看起来像这样:

一次导致文件被**截断**的登出操作,日志看起来是这样的:
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 版本、上游提交或邮件列表内容。
原始来源: Lobsters

评论 (0)